diff --git a/.claude/skills/ha-pr-comment-audit/SKILL.md b/.claude/skills/ha-pr-comment-audit/SKILL.md
new file mode 100644
index 000000000000..d949cbbe6e74
--- /dev/null
+++ b/.claude/skills/ha-pr-comment-audit/SKILL.md
@@ -0,0 +1,17 @@
+---
+name: ha-pr-comment-audit
+description: Audits the review comment threads on a Home Assistant GitHub pull request, flagging unaddressed comments and requests for clarification. Use when checking whether PR feedback has been handled, either standalone or as part of a full PR review.
+---
+
+# Check Home Assistant PR Review Comments
+
+## Instructions:
+- Resolve the PR context first. If a PR number is not given, use 'gh pr view' to identify the current branch's PR.
+- Fetch the review comment threads for the PR (e.g. 'gh api' for review threads/comments).
+- Flag comments that have not been addressed. If the author has replied but has not implemented the suggestion, still flag it and summarize the reply.
+- Flag comments for which the author has asked for clarification.
+- Generate a summary of the flagged comments, including a link for each comment. Don't include comments that have been addressed.
+
+
+## IMPORTANT:
+- Only provide feedback in the CONSOLE. DO NOT ACT ON GITHUB.
diff --git a/.claude/skills/ha-pr-reviewer/SKILL.md b/.claude/skills/ha-pr-reviewer/SKILL.md
index 370e6246725a..05060b3de2f0 100644
--- a/.claude/skills/ha-pr-reviewer/SKILL.md
+++ b/.claude/skills/ha-pr-reviewer/SKILL.md
@@ -5,11 +5,11 @@ description: Reviews Home Assistant GitHub pull requests and provides feedback c
# Review GitHub Pull Request
-## Follow these steps:
-1. Use 'gh pr view' to get the PR details and description.
-2. Use 'gh pr diff' to see all the changes in the PR.
-3. Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions.
-4. Check if all existing review comments have been addressed.
+## Instructions:
+- Use 'gh pr view' to get the PR details and description.
+- Use 'gh pr diff' to see all the changes in the PR.
+- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. Explicitly pass the PR's target/base branch to the `ha-review` skill (obtained via `gh pr view`) so it diffs against the correct base.
+- Run a subagent in parallel to check the PR review comments following the `ha-pr-comment-audit` skill.
## IMPORTANT:
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
diff --git a/.claude/skills/ha-review/SKILL.md b/.claude/skills/ha-review/SKILL.md
index f78cbe0dfd5f..12e7cb4318df 100644
--- a/.claude/skills/ha-review/SKILL.md
+++ b/.claude/skills/ha-review/SKILL.md
@@ -5,6 +5,9 @@ description: Reviews Home Assistant code changes and provides constructive feedb
# Review Code Changes
+## Scope:
+- Unless instructed otherwise, review the full branch changes against the target branch. Resolve the base to an available ref (prefer `upstream/`, then `origin/`, then local ``) and review `git diff "$(git merge-base "$BASE_REF" HEAD)"..HEAD`; use `dev` as the default base.
+
## Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
deleted file mode 100644
index 2783972953b3..000000000000
--- a/.github/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,49 +0,0 @@
-
-## The problem
-
-
-
-## Environment
-
-
-- Home Assistant Core release with the issue:
-- Last working Home Assistant Core release (if known):
-- Operating environment (OS/Container/Supervised/Core):
-- Integration causing this issue:
-- Link to integration documentation on our website:
-
-## Problem-relevant `configuration.yaml`
-
-
-```yaml
-
-```
-
-## Traceback/Error logs
-
-
-```txt
-
-```
-
-## Additional information
-
diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png
index abbc0f76ff02..47357c6581cc 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
index 30e3c670c6ae..5fa2da4a257b 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -8,6 +8,131 @@
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
+- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments. Even unchecked checkboxes or empty sections must be present. This is a hard requirement.
+
+## Pull Request template
+
+The PR description must follow this template (from `.github/PULL_REQUEST_TEMPLATE.md`):
+
+```markdown
+
+## Breaking change
+
+
+
+## Proposed change
+
+
+
+## Type of change
+
+
+- [ ] Dependency upgrade
+- [ ] Bugfix (non-breaking change which fixes an issue)
+- [ ] New integration (thank you!)
+- [ ] New feature (which adds functionality to an existing integration)
+- [ ] Deprecation (breaking change to happen in the future)
+- [ ] Breaking change (fix/feature causing existing functionality to break)
+- [ ] Code quality improvements to existing code or addition of tests
+
+## Additional information
+
+
+- This PR fixes or closes issue: fixes #
+- This PR is related to issue:
+- Link to documentation pull request:
+- Link to developer documentation pull request:
+- Link to frontend pull request:
+
+## Checklist
+
+
+- [ ] I understand the code I am submitting and can explain how it works.
+- [ ] The code change is tested and works locally.
+- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
+- [ ] There is no commented out code in this PR.
+- [ ] I have followed the [development checklist][dev-checklist]
+- [ ] I have followed the [perfect PR recommendations][perfect-pr]
+- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`)
+- [ ] Tests have been added to verify that the new code works.
+- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
+
+If user exposed functionality or configuration variables are added/changed:
+
+- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository]
+
+If the code communicates with devices, web services, or third-party tools:
+
+- [ ] The [manifest file][manifest-docs] has all fields filled out correctly.
+ Updated and included derived files by running: `python3 -m script.hassfest`.
+- [ ] New or updated dependencies have been added to `requirements_all.txt`.
+ Updated by running `python3 -m script.gen_requirements_all`.
+- [ ] For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.
+
+
+
+To help with the load of incoming pull requests:
+
+- [ ] I have reviewed two other [open pull requests][prs] in this repository.
+
+[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone+-status%3Afailure
+
+
+[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
+[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/
+[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/
+[docs-repository]: https://github.com/home-assistant/home-assistant.io
+[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
+```
# GitHub Copilot & Claude Code Instructions
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index ab765750ccdd..51f5a5f72da4 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -342,13 +342,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -378,7 +378,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -392,7 +392,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -521,14 +521,14 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -541,7 +541,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index e74f3e694ce3..e026da1a34b7 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -116,7 +116,7 @@ jobs:
# of a new uv cache entry after a version bump.
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
- name: Filter for core changes
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
+ uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: core
with:
filters: .core_files.yaml
@@ -131,7 +131,7 @@ jobs:
echo "Result:"
cat .integration_paths.yaml
- name: Filter for integration changes
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
+ uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: integrations
with:
filters: .integration_paths.yaml
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
- uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
+ uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
- uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
+ uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5
with:
extra-args: --all-files zizmor
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 31ef4c06f05b..4e1287cc395d 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+ uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+ uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: "/language:python"
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
new file mode 100644
index 000000000000..97fd2dfc6fdf
--- /dev/null
+++ b/.github/workflows/e2e-tests.yml
@@ -0,0 +1,95 @@
+name: E2E tests
+
+# yamllint disable-line rule:truthy
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Image tag or digest to test (e.g. dev, 2026.7.1, 2026.8.0b0, sha256:0a1b2c3d…)"
+ default: "dev"
+ required: true
+
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version }}
+ cancel-in-progress: true
+
+jobs:
+ boot_check:
+ name: Boot check ${{ matrix.arch }} core image
+ if: github.repository_owner == 'home-assistant'
+ runs-on: ${{ matrix.runs-on }}
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: amd64
+ runs-on: ubuntu-24.04
+ - arch: aarch64
+ runs-on: ubuntu-24.04-arm
+ env:
+ BASE_URL: http://localhost:8123
+ services:
+ homeassistant:
+ image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images]
+ ports:
+ - 8123:8123
+ # Gate steps until Home Assistant answers (60 x 5s ≈ 300s startup budget)
+ options: >-
+ --health-cmd="curl --fail --silent --max-time 10 --output /dev/null http://127.0.0.1:8123/"
+ --health-start-period=10s
+ --health-interval=5s
+ --health-retries=60
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up pnpm
+ uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+ with:
+ package_json_file: tests/e2e/package.json
+
+ - name: Set up Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: "24"
+ cache: pnpm
+ cache-dependency-path: tests/e2e/pnpm-lock.yaml
+
+ - name: Install E2E test dependencies
+ working-directory: tests/e2e
+ run: pnpm install --frozen-lockfile
+
+ - name: Install Playwright browser
+ working-directory: tests/e2e
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Run Playwright E2E tests
+ working-directory: tests/e2e
+ run: pnpm exec playwright test
+
+ - name: Upload Playwright report
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: playwright-report-${{ matrix.arch }}
+ path: |
+ tests/e2e/playwright-report/
+ tests/e2e/test-results/
+
+ - name: Dump container logs
+ if: always()
+ env:
+ CONTAINER: ${{ job.services.homeassistant.id }}
+ run: docker logs "$CONTAINER" > homeassistant.log 2>&1 || true
+
+ - name: Upload container logs
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: container-logs-${{ matrix.arch }}
+ path: homeassistant.log
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 06f1638125f6..91798343783d 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -42,7 +42,7 @@ jobs:
# - Issues
# - No issues marked as no-stale or help-wanted
- name: 60 days stale PRs policy and 90 days stale issue policy
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
+ uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
repo-token: ${{ steps.token.outputs.token }}
remove-stale-when-updated: true
diff --git a/.gitignore b/.gitignore
index 9d8cbaf15e09..5fb2ad904d14 100644
--- a/.gitignore
+++ b/.gitignore
@@ -145,3 +145,7 @@ pytest_buckets.txt
.claude/worktrees/
.serena/
+# Playwright e2e tests
+tests/e2e/node_modules/
+tests/e2e/playwright-report/
+tests/e2e/test-results/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 191130dd5c05..bd15be304e0b 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.15.20
+ rev: v0.15.21
hooks:
- id: ruff-check
args:
diff --git a/.prettierignore b/.prettierignore
index c63290996661..54c2d65e4d62 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,3 +5,4 @@ homeassistant/generated/*
tests/components/lidarr/fixtures/initialize.js
tests/components/lidarr/fixtures/initialize-wrong.js
tests/fixtures/core/config/yaml_errors/
+tests/e2e/pnpm-lock.yaml
diff --git a/.strict-typing b/.strict-typing
index 66075b93743b..e7e3bd1c8870 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -228,6 +228,7 @@ homeassistant.components.fujitsu_fglair.*
homeassistant.components.fully_kiosk.*
homeassistant.components.fumis.*
homeassistant.components.fyta.*
+homeassistant.components.gatus.*
homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.*
@@ -242,6 +243,7 @@ homeassistant.components.google.*
homeassistant.components.google_assistant_sdk.*
homeassistant.components.google_cloud.*
homeassistant.components.google_drive.*
+homeassistant.components.google_health.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.*
homeassistant.components.google_weather.*
@@ -254,6 +256,7 @@ homeassistant.components.guntamatic.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
+homeassistant.components.harman_luxury.*
homeassistant.components.hdfury.*
homeassistant.components.heos.*
homeassistant.components.here_travel_time.*
@@ -335,6 +338,7 @@ homeassistant.components.lawn_mower.*
homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
+homeassistant.components.led_infrared.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
@@ -346,7 +350,9 @@ homeassistant.components.lifx.*
homeassistant.components.light.*
homeassistant.components.linkplay.*
homeassistant.components.litejet.*
+homeassistant.components.litellm.*
homeassistant.components.litterrobot.*
+homeassistant.components.llama_cpp.*
homeassistant.components.local_ip.*
homeassistant.components.local_todo.*
homeassistant.components.lock.*
@@ -393,6 +399,7 @@ homeassistant.components.nam.*
homeassistant.components.namecheapdns.*
homeassistant.components.nasweb.*
homeassistant.components.neato.*
+homeassistant.components.neopool.*
homeassistant.components.nest.*
homeassistant.components.netatmo.*
homeassistant.components.network.*
diff --git a/CODEOWNERS b/CODEOWNERS
index 409c3e54bdfa..5c6830e92882 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -607,6 +607,8 @@ CLAUDE.md @home-assistant/core
/tests/components/frontend/ @home-assistant/frontend
/homeassistant/components/frontier_silicon/ @wlcrs
/tests/components/frontier_silicon/ @wlcrs
+/homeassistant/components/fuelprices_dk/ @MTrab
+/tests/components/fuelprices_dk/ @MTrab
/homeassistant/components/fujitsu_fglair/ @crevetor
/tests/components/fujitsu_fglair/ @crevetor
/homeassistant/components/fully_kiosk/ @cgarwood
@@ -623,6 +625,8 @@ CLAUDE.md @home-assistant/core
/tests/components/gardena_bluetooth/ @elupus
/homeassistant/components/gate/ @home-assistant/core
/tests/components/gate/ @home-assistant/core
+/homeassistant/components/gatus/ @TN-1
+/tests/components/gatus/ @TN-1
/homeassistant/components/gdacs/ @exxamalte
/tests/components/gdacs/ @exxamalte
/homeassistant/components/generic/ @davet2001
@@ -675,6 +679,8 @@ CLAUDE.md @home-assistant/core
/tests/components/google_drive/ @tronikos
/homeassistant/components/google_generative_ai_conversation/ @tronikos @ivanlh
/tests/components/google_generative_ai_conversation/ @tronikos @ivanlh
+/homeassistant/components/google_health/ @allenporter
+/tests/components/google_health/ @allenporter
/homeassistant/components/google_mail/ @tkdrob
/tests/components/google_mail/ @tkdrob
/homeassistant/components/google_photos/ @allenporter
@@ -713,10 +719,14 @@ CLAUDE.md @home-assistant/core
/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hanna/ @bestycame
/tests/components/hanna/ @bestycame
+/homeassistant/components/harbor/ @Lash-L @afgarcia86
+/tests/components/harbor/ @Lash-L @afgarcia86
/homeassistant/components/hardkernel/ @home-assistant/core
/tests/components/hardkernel/ @home-assistant/core
/homeassistant/components/hardware/ @home-assistant/core
/tests/components/hardware/ @home-assistant/core
+/homeassistant/components/harman_luxury/ @sbesh91
+/tests/components/harman_luxury/ @sbesh91
/homeassistant/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/homeassistant/components/hassio/ @home-assistant/supervisor
@@ -777,8 +787,8 @@ CLAUDE.md @home-assistant/core
/tests/components/homematicip_cloud/ @hahn-th @lackas
/homeassistant/components/homevolt/ @danielhiversen @liudger
/tests/components/homevolt/ @danielhiversen @liudger
-/homeassistant/components/homewizard/ @DCSBL
-/tests/components/homewizard/ @DCSBL
+/homeassistant/components/homewizard/ @DCSBL @lexpostma
+/tests/components/homewizard/ @DCSBL @lexpostma
/homeassistant/components/honeywell/ @mkmer
/tests/components/honeywell/ @mkmer
/homeassistant/components/honeywell_string_lights/ @balloob
@@ -938,6 +948,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/kaiterra/ @Michsior14
/homeassistant/components/kaleidescape/ @SteveEasley
/tests/components/kaleidescape/ @SteveEasley
+/homeassistant/components/karakeep/ @sli-cka
+/tests/components/karakeep/ @sli-cka
/homeassistant/components/keba/ @dannerph
/homeassistant/components/keenetic_ndms2/ @foxel
/tests/components/keenetic_ndms2/ @foxel
@@ -993,6 +1005,8 @@ CLAUDE.md @home-assistant/core
/tests/components/leaone/ @bdraco
/homeassistant/components/led_ble/ @bdraco
/tests/components/led_ble/ @bdraco
+/homeassistant/components/led_infrared/ @tr4nt0r
+/tests/components/led_infrared/ @tr4nt0r
/homeassistant/components/lektrico/ @lektrico
/tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom
@@ -1022,10 +1036,14 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/linux_battery/ @fabaff
/homeassistant/components/litejet/ @joncar
/tests/components/litejet/ @joncar
+/homeassistant/components/litellm/ @luismalves
+/tests/components/litellm/ @luismalves
/homeassistant/components/litterrobot/ @natekspencer @tkdrob
/tests/components/litterrobot/ @natekspencer @tkdrob
/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet
/tests/components/livisi/ @StefanIacobLivisi @planbnet
+/homeassistant/components/llama_cpp/ @allenporter
+/tests/components/llama_cpp/ @allenporter
/homeassistant/components/llm/ @home-assistant/core
/tests/components/llm/ @home-assistant/core
/homeassistant/components/local_calendar/ @allenporter
@@ -1051,6 +1069,7 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/lovelace/ @home-assistant/frontend
/tests/components/lovelace/ @home-assistant/frontend
/homeassistant/components/luci/ @mzdrale
+/tests/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck
/homeassistant/components/lunatone/ @MoonDevLT
@@ -1061,6 +1080,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lutron/ @cdheiser @wilburCForce
/homeassistant/components/lutron_caseta/ @swails @danaues @eclair4151
/tests/components/lutron_caseta/ @swails @danaues @eclair4151
+/homeassistant/components/lyngdorf/ @fishloa
+/tests/components/lyngdorf/ @fishloa
/homeassistant/components/lyric/ @timmo001
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
@@ -1115,8 +1136,8 @@ CLAUDE.md @home-assistant/core
/tests/components/microbees/ @microBeesTech
/homeassistant/components/miele/ @astrandb
/tests/components/miele/ @astrandb
-/homeassistant/components/mikrotik/ @engrbm87
-/tests/components/mikrotik/ @engrbm87
+/homeassistant/components/mikrotik/ @engrbm87 @chemelli74
+/tests/components/mikrotik/ @engrbm87 @chemelli74
/homeassistant/components/mill/ @danielhiversen
/tests/components/mill/ @danielhiversen
/homeassistant/components/min_max/ @gjohansson-ST
@@ -1139,8 +1160,8 @@ CLAUDE.md @home-assistant/core
/tests/components/moehlenhoff_alpha2/ @j-a-n
/homeassistant/components/moisture/ @home-assistant/core
/tests/components/moisture/ @home-assistant/core
-/homeassistant/components/monarch_money/ @jeeftor
-/tests/components/monarch_money/ @jeeftor
+/homeassistant/components/monarch_money/ @jeeftor @bradleyseanf
+/tests/components/monarch_money/ @jeeftor @bradleyseanf
/homeassistant/components/monoprice/ @etsinko @OnFreund
/tests/components/monoprice/ @etsinko @OnFreund
/homeassistant/components/monzo/ @jakemartin-icl
@@ -1189,6 +1210,8 @@ CLAUDE.md @home-assistant/core
/tests/components/nasweb/ @nasWebio
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
+/homeassistant/components/neopool/ @svasek
+/tests/components/neopool/ @svasek
/homeassistant/components/ness_alarm/ @nickw444 @poshy163
/tests/components/ness_alarm/ @nickw444 @poshy163
/homeassistant/components/nest/ @allenporter
@@ -1360,8 +1383,6 @@ CLAUDE.md @home-assistant/core
/tests/components/peco/ @IceBotYT
/homeassistant/components/pegel_online/ @mib1185
/tests/components/pegel_online/ @mib1185
-/homeassistant/components/permobil/ @IsakNyberg
-/tests/components/permobil/ @IsakNyberg
/homeassistant/components/persistent_notification/ @home-assistant/core
/tests/components/persistent_notification/ @home-assistant/core
/homeassistant/components/pglab/ @pglab-electronics
@@ -1577,6 +1598,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/schlage/ @dknowles2
/tests/components/schlage/ @dknowles2
/homeassistant/components/schluter/ @prairieapps
+/homeassistant/components/scorpiontrack/ @Herbertmt978
+/tests/components/scorpiontrack/ @Herbertmt978
/homeassistant/components/scrape/ @fabaff @gjohansson-ST
/tests/components/scrape/ @fabaff @gjohansson-ST
/homeassistant/components/screenlogic/ @dieselrabbit @bdraco
@@ -1696,8 +1719,8 @@ CLAUDE.md @home-assistant/core
/tests/components/sonarr/ @ctalkington
/homeassistant/components/songpal/ @rytilahti @shenxn
/tests/components/songpal/ @rytilahti @shenxn
-/homeassistant/components/sonos/ @jjlawren @peterager
-/tests/components/sonos/ @jjlawren @peterager
+/homeassistant/components/sonos/ @peterager @jjlawren
+/tests/components/sonos/ @peterager @jjlawren
/homeassistant/components/soundtouch/ @kroimon
/tests/components/soundtouch/ @kroimon
/homeassistant/components/spaceapi/ @fabaff
@@ -1718,8 +1741,8 @@ CLAUDE.md @home-assistant/core
/tests/components/starline/ @anonym-tsk
/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
/tests/components/statistics/ @ThomDietrich @gjohansson-ST
-/homeassistant/components/steam_online/ @tkdrob
-/tests/components/steam_online/ @tkdrob
+/homeassistant/components/steam_online/ @tr4nt0r @tkdrob
+/tests/components/steam_online/ @tr4nt0r @tkdrob
/homeassistant/components/steamist/ @bdraco
/tests/components/steamist/ @bdraco
/homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS
@@ -1805,8 +1828,8 @@ CLAUDE.md @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
-/homeassistant/components/tesla_wall_connector/ @einarhauks
-/tests/components/tesla_wall_connector/ @einarhauks
+/homeassistant/components/tesla_wall_connector/ @einarhauks @sarabveer
+/tests/components/tesla_wall_connector/ @einarhauks @sarabveer
/homeassistant/components/teslemetry/ @Bre77
/tests/components/teslemetry/ @Bre77
/homeassistant/components/tessie/ @Bre77
@@ -1946,6 +1969,8 @@ CLAUDE.md @home-assistant/core
/tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
+/homeassistant/components/vibration/ @home-assistant/core
+/tests/components/vibration/ @home-assistant/core
/homeassistant/components/vicare/ @CFenner @lackas
/tests/components/vicare/ @CFenner @lackas
/homeassistant/components/victron_ble/ @rajlaud
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index 81a9fca6160f..0c606c38d080 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -47,7 +47,7 @@ from .components import (
file_upload as file_upload_pre_import, # noqa: F401
group as group_pre_import, # noqa: F401
history as history_pre_import, # noqa: F401
- http, # not named pre_import since it has requirements
+ http as http_import, # noqa: F401 - not named pre_import since it has requirements
image_upload as image_upload_import, # noqa: F401 - not named pre_import since it has requirements
logbook as logbook_pre_import, # noqa: F401
lovelace as lovelace_pre_import, # noqa: F401
@@ -264,6 +264,7 @@ DEFAULT_INTEGRATIONS = {
"occupancy",
"power",
"temperature",
+ "vibration",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
@@ -414,12 +415,7 @@ async def async_setup_hass(
_LOGGER.info("Starting in recovery mode")
hass.config.recovery_mode = True
- http_conf = (await http.async_get_last_config(hass)) or {}
-
- await async_from_config_dict(
- {"recovery_mode": {}, "http": http_conf},
- hass,
- )
+ await async_from_config_dict({"recovery_mode": {}}, hass)
if runtime_config.open_ui:
hass.add_job(open_hass_ui, hass)
diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json
index 117b7c6b63dd..4f3c62067ab4 100644
--- a/homeassistant/brands/google.json
+++ b/homeassistant/brands/google.json
@@ -8,6 +8,7 @@
"google_cloud",
"google_drive",
"google_generative_ai_conversation",
+ "google_health",
"google_mail",
"google_maps",
"google_photos",
diff --git a/homeassistant/brands/mitsubishi.json b/homeassistant/brands/mitsubishi.json
index c5c0a2d9c909..830a04566840 100644
--- a/homeassistant/brands/mitsubishi.json
+++ b/homeassistant/brands/mitsubishi.json
@@ -1,5 +1,5 @@
{
"domain": "mitsubishi",
"name": "Mitsubishi",
- "integrations": ["melcloud", "mitsubishi_comfort"]
+ "integrations": ["melcloud", "melcloud_home", "mitsubishi_comfort"]
}
diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py
index 34b8a713de63..979ebf3ed9b4 100644
--- a/homeassistant/components/acaia/binary_sensor.py
+++ b/homeassistant/components/acaia/binary_sensor.py
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import override
-from aioacaia.acaiascale import AcaiaScale
+from aioacaia import AcaiaScale
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py
index 1e260ee4e74d..120933092375 100644
--- a/homeassistant/components/acaia/button.py
+++ b/homeassistant/components/acaia/button.py
@@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, override
-from aioacaia.acaiascale import AcaiaScale
+from aioacaia import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py
index 6cceb21ff8bc..a52c5a009f80 100644
--- a/homeassistant/components/acaia/config_flow.py
+++ b/homeassistant/components/acaia/config_flow.py
@@ -3,8 +3,8 @@
import logging
from typing import Any, override
+from aioacaia.discovery import is_new_scale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
-from aioacaia.helpers import is_new_scale
import voluptuous as vol
from homeassistant.components.bluetooth import (
diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py
index f9847c31a8d2..06338078dd66 100644
--- a/homeassistant/components/acaia/coordinator.py
+++ b/homeassistant/components/acaia/coordinator.py
@@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from typing import override
-from aioacaia.acaiascale import AcaiaScale
+from aioacaia import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from homeassistant.components.bluetooth import async_get_scanner
diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json
index e6fdea2600e2..25472715d469 100644
--- a/homeassistant/components/acaia/manifest.json
+++ b/homeassistant/components/acaia/manifest.json
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
- "requirements": ["aioacaia==0.1.18"]
+ "requirements": ["aioacaia==0.2.0"]
}
diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py
index 735aacd78d59..0c74f50871bd 100644
--- a/homeassistant/components/acaia/sensor.py
+++ b/homeassistant/components/acaia/sensor.py
@@ -4,8 +4,9 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import override
-from aioacaia.acaiascale import AcaiaDeviceState, AcaiaScale
+from aioacaia import AcaiaScale
from aioacaia.const import UnitMass as AcaiaUnitOfMass
+from aioacaia.scale import AcaiaDeviceState
from homeassistant.components.sensor import (
RestoreSensor,
diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py
index e88a2960379f..3840fc515b0a 100644
--- a/homeassistant/components/ai_task/__init__.py
+++ b/homeassistant/components/ai_task/__init__.py
@@ -5,6 +5,7 @@ from typing import Any
import voluptuous as vol
+from homeassistant.components.media_source import local_source
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR
from homeassistant.core import (
@@ -34,6 +35,7 @@ from .const import (
)
from .entity import AITaskEntity
from .http import async_setup as async_setup_http
+from .media_source import async_get_media_source
from .task import (
GenDataTask,
GenDataTaskResult,
@@ -88,6 +90,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
await hass.data[DATA_PREFERENCES].async_load()
async_setup_http(hass)
+ if hass.config.media_dirs:
+ source = await async_get_media_source(hass)
+ hass.http.register_view(local_source.LocalMediaView(hass, source))
hass.services.async_register(
DOMAIN,
SERVICE_GENERATE_DATA,
diff --git a/homeassistant/components/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py
index 9f0e493b0ada..378809eee554 100644
--- a/homeassistant/components/ai_task/media_source.py
+++ b/homeassistant/components/ai_task/media_source.py
@@ -2,14 +2,16 @@
from pathlib import Path
-from homeassistant.components.media_source import MediaSource, local_source
+from homeassistant.components.media_source import local_source
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.singleton import singleton
from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR
-async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
+@singleton(DATA_MEDIA_SOURCE, async_=True)
+async def async_get_media_source(hass: HomeAssistant) -> local_source.LocalSource:
"""Set up local media source."""
media_dirs = list(hass.config.media_dirs.values())
@@ -20,11 +22,10 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR
- hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
+ return local_source.LocalSource(
hass,
DOMAIN,
"AI generated images",
{IMAGE_DIR: str(media_dir)},
f"/{DOMAIN}",
)
- return source
diff --git a/homeassistant/components/aidot/coordinator.py b/homeassistant/components/aidot/coordinator.py
index 7ec6a46ecd03..b751ac4af0f8 100644
--- a/homeassistant/components/aidot/coordinator.py
+++ b/homeassistant/components/aidot/coordinator.py
@@ -163,6 +163,4 @@ class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]):
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
- device_reg.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_reg.async_remove_device(device.id)
diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py
index 89ff2a45f9ac..3a0dfa49742e 100644
--- a/homeassistant/components/airnow/config_flow.py
+++ b/homeassistant/components/airnow/config_flow.py
@@ -38,11 +38,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
lat = data[CONF_LATITUDE]
lng = data[CONF_LONGITUDE]
- distance = data[CONF_RADIUS]
# Check that the provided latitude/longitude provide a response
try:
- test_data = await client.observations.latLong(lat, lng, distance=distance)
+ test_data = await client.observations.latLong(lat, lng)
except InvalidKeyError as exc:
raise InvalidAuth from exc
diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py
index f96c0e66a16e..020aecec00f8 100644
--- a/homeassistant/components/airnow/coordinator.py
+++ b/homeassistant/components/airnow/coordinator.py
@@ -77,7 +77,6 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
obs = await self.airnow.observations.latLong(
self.latitude,
self.longitude,
- distance=self.distance,
)
except (AirNowError, ClientConnectorError, InvalidJsonError) as error:
diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json
index da1c936b68fb..fa321fe1a158 100644
--- a/homeassistant/components/airnow/manifest.json
+++ b/homeassistant/components/airnow/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyairnow"],
- "requirements": ["pyairnow==1.3.1"]
+ "requirements": ["pyairnow==1.4.0"]
}
diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py
index 24adef5dbcd1..1e35887be249 100644
--- a/homeassistant/components/airobot/button.py
+++ b/homeassistant/components/airobot/button.py
@@ -4,11 +4,7 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, override
-from pyairobotrest.exceptions import (
- AirobotConnectionError,
- AirobotError,
- AirobotTimeoutError,
-)
+from pyairobotrest.exceptions import AirobotError
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -84,10 +80,6 @@ class AirobotButton(AirobotEntity, ButtonEntity):
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
- # pylint: disable-next=home-assistant-action-swallowed-exception
- except AirobotConnectionError, AirobotTimeoutError:
- # Connection errors during reboot are expected as device restarts
- pass
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/airobot/manifest.json b/homeassistant/components/airobot/manifest.json
index 6a2e01f07325..76ef0a73e01e 100644
--- a/homeassistant/components/airobot/manifest.json
+++ b/homeassistant/components/airobot/manifest.json
@@ -13,5 +13,5 @@
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "platinum",
- "requirements": ["pyairobotrest==0.3.0"]
+ "requirements": ["pyairobotrest==0.4.0"]
}
diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py
index ecfcd026b665..1188cfd443bd 100644
--- a/homeassistant/components/airthings/sensor.py
+++ b/homeassistant/components/airthings/sensor.py
@@ -16,6 +16,7 @@ from homeassistant.const import (
EntityCategory,
UnitOfDensity,
UnitOfPressure,
+ UnitOfRadiationConcentration,
UnitOfRatio,
UnitOfSoundPressure,
UnitOfTemperature,
@@ -33,36 +34,34 @@ from .coordinator import AirthingsDataUpdateCoordinator
SENSORS: dict[str, SensorEntityDescription] = {
"radonShortTermAvg": SensorEntityDescription(
key="radonShortTermAvg",
- native_unit_of_measurement="Bq/m³",
- translation_key="radon",
+ device_class=SensorDeviceClass.RADON,
+ native_unit_of_measurement=(
+ UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER
+ ),
suggested_display_precision=0,
),
"temp": SensorEntityDescription(
key="temp",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"humidity": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"pressure": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"sla": SensorEntityDescription(
key="sla",
device_class=SensorDeviceClass.SOUND_PRESSURE,
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"battery": SensorEntityDescription(
@@ -70,47 +69,40 @@ SENSORS: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"co2": SensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"voc": SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=UnitOfRatio.PARTS_PER_BILLION,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"light": SensorEntityDescription(
key="light",
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
translation_key="light",
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"lux": SensorEntityDescription(
key="lux",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"virusRisk": SensorEntityDescription(
key="virusRisk",
translation_key="virus_risk",
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"mold": SensorEntityDescription(
key="mold",
translation_key="mold",
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"rssi": SensorEntityDescription(
@@ -119,21 +111,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"pm1": SensorEntityDescription(
key="pm1",
native_unit_of_measurement=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM1,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"pm25": SensorEntityDescription(
key="pm25",
native_unit_of_measurement=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM25,
- state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
}
@@ -145,8 +134,8 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Airthings sensor."""
-
coordinator = entry.runtime_data
+
entities = [
AirthingsDeviceSensor(
coordinator,
diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json
index 5e6d58addbe5..3c3e5dfc636d 100644
--- a/homeassistant/components/airthings/strings.json
+++ b/homeassistant/components/airthings/strings.json
@@ -26,9 +26,6 @@
"mold": {
"name": "Mold"
},
- "radon": {
- "name": "Radon"
- },
"virus_risk": {
"name": "Virus Risk"
}
diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py
index 43b6268bd093..195bd0e74cfa 100644
--- a/homeassistant/components/airthings_ble/const.py
+++ b/homeassistant/components/airthings_ble/const.py
@@ -5,9 +5,6 @@ from airthings_ble import AirthingsDeviceType
DOMAIN = "airthings_ble"
MFCT_ID = 820
-VOLUME_BECQUEREL = "Bq/m³"
-VOLUME_PICOCURIE = "pCi/L"
-
DEVICE_MODEL = "device_model"
DEFAULT_SCAN_INTERVAL = 300
diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py
index ca580483e373..7c7284f7e751 100644
--- a/homeassistant/components/airthings_ble/coordinator.py
+++ b/homeassistant/components/airthings_ble/coordinator.py
@@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import (
DEFAULT_SCAN_INTERVAL,
@@ -36,9 +35,7 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
def __init__(self, hass: HomeAssistant, entry: AirthingsBLEConfigEntry) -> None:
"""Initialize the coordinator."""
- self.airthings = AirthingsBluetoothDeviceData(
- _LOGGER, hass.config.units is METRIC_SYSTEM
- )
+ self.airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric=True)
device_model = entry.data.get(DEVICE_MODEL)
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
diff --git a/homeassistant/components/airthings_ble/icons.json b/homeassistant/components/airthings_ble/icons.json
index 04b951999baf..600e0d3f6e77 100644
--- a/homeassistant/components/airthings_ble/icons.json
+++ b/homeassistant/components/airthings_ble/icons.json
@@ -9,15 +9,9 @@
"smartlink": "mdi:hub"
}
},
- "radon_1day_avg": {
- "default": "mdi:radioactive"
- },
"radon_1day_level": {
"default": "mdi:radioactive"
},
- "radon_longterm_avg": {
- "default": "mdi:radioactive"
- },
"radon_longterm_level": {
"default": "mdi:radioactive"
}
diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py
index b707e8c2a921..afeaacc62f16 100644
--- a/homeassistant/components/airthings_ble/sensor.py
+++ b/homeassistant/components/airthings_ble/sensor.py
@@ -1,7 +1,6 @@
"""Support for airthings ble sensors."""
from collections.abc import Callable
-import dataclasses
from dataclasses import dataclass
import logging
from typing import override
@@ -19,6 +18,7 @@ from homeassistant.const import (
EntityCategory,
Platform,
UnitOfPressure,
+ UnitOfRadiationConcentration,
UnitOfRatio,
UnitOfSoundPressure,
UnitOfTemperature,
@@ -33,9 +33,8 @@ from homeassistant.helpers.entity_registry import (
)
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from homeassistant.util.unit_system import METRIC_SYSTEM
-from .const import DOMAIN, VOLUME_BECQUEREL, VOLUME_PICOCURIE
+from .const import DOMAIN
from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -65,15 +64,15 @@ SENSORS_MAPPING_TEMPLATE: dict[str, AirthingsBLESensorEntityDescription] = {
"radon_1day_avg": AirthingsBLESensorEntityDescription(
key="radon_1day_avg",
translation_key="radon_1day_avg",
- native_unit_of_measurement=VOLUME_BECQUEREL,
- suggested_display_precision=0,
+ device_class=SensorDeviceClass.RADON,
+ native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
"radon_longterm_avg": AirthingsBLESensorEntityDescription(
key="radon_longterm_avg",
translation_key="radon_longterm_avg",
- native_unit_of_measurement=VOLUME_BECQUEREL,
- suggested_display_precision=0,
+ device_class=SensorDeviceClass.RADON,
+ native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
"radon_1day_level": AirthingsBLESensorEntityDescription(
@@ -210,26 +209,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Airthings BLE sensors."""
- is_metric = hass.config.units is METRIC_SYSTEM
-
coordinator = entry.runtime_data
- # we need to change some units
- sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy()
- if not is_metric:
- for key, val in sensors_mapping.items():
- if val.native_unit_of_measurement is not VOLUME_BECQUEREL:
- continue
- sensors_mapping[key] = dataclasses.replace(
- val,
- native_unit_of_measurement=VOLUME_PICOCURIE,
- suggested_display_precision=1,
- )
-
entities = []
_LOGGER.debug("got sensors: %s", coordinator.data.sensors)
for sensor_type, sensor_value in coordinator.data.sensors.items():
- if sensor_type not in sensors_mapping:
+ if sensor_type not in SENSORS_MAPPING_TEMPLATE:
_LOGGER.debug(
"Unknown sensor type detected: %s, %s",
sensor_type,
@@ -238,7 +223,9 @@ async def async_setup_entry(
continue
async_migrate(hass, coordinator.data.address, sensor_type)
entities.append(
- AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type])
+ AirthingsSensor(
+ coordinator, coordinator.data, SENSORS_MAPPING_TEMPLATE[sensor_type]
+ )
)
async_add_entities(entities)
diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py
index d6d3380e72d9..879bf6ec932f 100644
--- a/homeassistant/components/airvisual/sensor.py
+++ b/homeassistant/components/airvisual/sensor.py
@@ -9,14 +9,13 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
ATTR_STATE,
CONF_COUNTRY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_SHOW_ON_MAP,
CONF_STATE,
+ EntityStateAttribute,
UnitOfDensity,
UnitOfRatio,
)
@@ -191,12 +190,14 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity):
)
if self.coordinator.config_entry.options[CONF_SHOW_ON_MAP]:
- self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude
- self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude
+ self._attr_extra_state_attributes[EntityStateAttribute.LATITUDE] = latitude
+ self._attr_extra_state_attributes[EntityStateAttribute.LONGITUDE] = (
+ longitude
+ )
self._attr_extra_state_attributes.pop("lati", None)
self._attr_extra_state_attributes.pop("long", None)
else:
self._attr_extra_state_attributes["lati"] = latitude
self._attr_extra_state_attributes["long"] = longitude
- self._attr_extra_state_attributes.pop(ATTR_LATITUDE, None)
- self._attr_extra_state_attributes.pop(ATTR_LONGITUDE, None)
+ self._attr_extra_state_attributes.pop(EntityStateAttribute.LATITUDE, None)
+ self._attr_extra_state_attributes.pop(EntityStateAttribute.LONGITUDE, None)
diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py
index 516988da4510..1e5cf061a6bd 100644
--- a/homeassistant/components/aladdin_connect/__init__.py
+++ b/homeassistant/components/aladdin_connect/__init__.py
@@ -111,6 +111,4 @@ def remove_stale_devices(
break
if device_id and device_id not in all_device_ids:
- device_registry.async_update_device(
- device_entry.id, remove_config_entry_id=config_entry.entry_id
- )
+ device_registry.async_remove_device(device_entry.id)
diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py
index 1a45247dae8d..e418903edd37 100644
--- a/homeassistant/components/aladdin_connect/api.py
+++ b/homeassistant/components/aladdin_connect/api.py
@@ -41,7 +41,6 @@ class AsyncConfigEntryAuth(Auth):
@override
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
- if not self._oauth_session.valid_token:
- await self._oauth_session.async_ensure_token_valid()
+ await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])
diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py
index cdbb561ceb77..71e6a83702ff 100644
--- a/homeassistant/components/alexa_devices/__init__.py
+++ b/homeassistant/components/alexa_devices/__init__.py
@@ -1,7 +1,7 @@
"""Alexa Devices integration."""
-from homeassistant.const import CONF_COUNTRY, Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.const import CONF_COUNTRY, EVENT_HOMEASSISTANT_STOP, Platform
+from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
@@ -56,7 +56,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
on_reauth_required=_on_http2_reauth_required,
)
- entry.async_on_unload(coordinator.api.stop_http2_processing)
+ async def _async_stop_http2(_event: Event | None = None) -> None:
+ """Stop HTTP/2 processing on entry unload or HA shutdown."""
+ await coordinator.api.stop_http2_processing()
+
+ entry.async_on_unload(_async_stop_http2)
+
+ entry.async_on_unload(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_http2)
+ )
entry.runtime_data = coordinator
diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py
index d4d56033c45a..a9712201ca50 100644
--- a/homeassistant/components/alexa_devices/button.py
+++ b/homeassistant/components/alexa_devices/button.py
@@ -1,20 +1,43 @@
"""Support for buttons."""
-from typing import override
+from dataclasses import dataclass
+from typing import Final, override
-from homeassistant.components.button import ButtonEntity
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
-from .entity import AmazonServiceEntity
+from .entity import AmazonEntity, AmazonServiceEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
+@dataclass(frozen=True, kw_only=True)
+class AmazonButtonEntityDescription(ButtonEntityDescription):
+ """Amazon Devices button entity description."""
+
+ capability: str
+
+
+DEVICE_BUTTONS: Final = {
+ AmazonButtonEntityDescription(
+ key="restart",
+ device_class=ButtonDeviceClass.RESTART,
+ entity_category=EntityCategory.CONFIG,
+ capability="ALEXA_DEVICE_REBOOT",
+ ),
+}
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
@@ -24,8 +47,9 @@ async def async_setup_entry(
coordinator = entry.runtime_data
known_routines: set[str] = set()
+ known_devices: set[str] = set()
- def _check_routines() -> None:
+ def _check_routines_devices() -> None:
current_routines = set(coordinator.api.routines)
new_routines = current_routines - known_routines
if new_routines:
@@ -34,8 +58,19 @@ async def async_setup_entry(
AmazonRoutineButton(coordinator, routine) for routine in new_routines
)
- _check_routines()
- entry.async_on_unload(coordinator.async_add_listener(_check_routines))
+ current_devices = set(coordinator.data)
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ AmazonDeviceButton(coordinator, serial_num, button_desc)
+ for button_desc in DEVICE_BUTTONS
+ for serial_num in new_devices
+ if button_desc.capability in coordinator.data[serial_num].capabilities
+ )
+
+ _check_routines_devices()
+ entry.async_on_unload(coordinator.async_add_listener(_check_routines_devices))
class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
@@ -54,3 +89,13 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
"""Handle button press action."""
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_routine(self._routine)
+
+
+class AmazonDeviceButton(AmazonEntity, ButtonEntity):
+ """Button entity for Alexa device."""
+
+ @override
+ async def async_press(self) -> None:
+ """Handle button press action."""
+ async with alexa_api_call(self.coordinator):
+ await self.coordinator.api.restart_device(self.device)
diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json
index 330a41dccf27..82fe8b3ce90a 100644
--- a/homeassistant/components/alexa_devices/manifest.json
+++ b/homeassistant/components/alexa_devices/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
- "requirements": ["aioamazondevices==14.1.8"]
+ "requirements": ["aioamazondevices==14.2.0"]
}
diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py
index 953743c66a6a..aa68ddbf5244 100644
--- a/homeassistant/components/ambient_station/__init__.py
+++ b/homeassistant/components/ambient_station/__init__.py
@@ -106,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 1 -> 2: Unique ID format changed, so delete and re-import:
if version == 1:
dev_reg = dr.async_get(hass)
- dev_reg.async_clear_config_entry(entry.entry_id)
+ dev_reg.async_clear_config_entry(entry.entry_id, entry.domain)
en_reg = er.async_get(hass)
en_reg.async_clear_config_entry(entry.entry_id)
diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py
index 920c36d97b7b..fd30f1c6fafb 100644
--- a/homeassistant/components/analytics/analytics.py
+++ b/homeassistant/components/analytics/analytics.py
@@ -28,9 +28,9 @@ from homeassistant.components.recorder import (
)
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import (
- ATTR_ASSUMED_STATE,
ATTR_DOMAIN,
BASE_PLATFORMS,
+ EntityStateAttribute,
__version__ as HA_VERSION,
)
from homeassistant.core import (
@@ -915,7 +915,9 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
# It is also not present, if entity is not in the state machine,
# which can happen for disabled entities.
"assumed_state": (
- entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
+ entity_state.attributes.get(
+ EntityStateAttribute.ASSUMED_STATE, False
+ )
if entity_state is not None
else None
),
diff --git a/homeassistant/components/analytics_insights/quality_scale.yaml b/homeassistant/components/analytics_insights/quality_scale.yaml
index e842dc6f3b1f..3455dc26750b 100644
--- a/homeassistant/components/analytics_insights/quality_scale.yaml
+++ b/homeassistant/components/analytics_insights/quality_scale.yaml
@@ -51,7 +51,7 @@ rules:
status: done
comment: |
The coordinator handles this.
- parallel-updates: todo
+ parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py
index 05136dc90611..f68a697decae 100644
--- a/homeassistant/components/analytics_insights/sensor.py
+++ b/homeassistant/components/analytics_insights/sensor.py
@@ -20,6 +20,8 @@ from . import AnalyticsInsightsConfigEntry
from .const import DOMAIN
from .coordinator import AnalyticsData, HomeassistantAnalyticsDataUpdateCoordinator
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class AnalyticsSensorEntityDescription(SensorEntityDescription):
diff --git a/homeassistant/components/androidtv/diagnostics.py b/homeassistant/components/androidtv/diagnostics.py
index 47cf6aa5ea88..e7f2cdb540c8 100644
--- a/homeassistant/components/androidtv/diagnostics.py
+++ b/homeassistant/components/androidtv/diagnostics.py
@@ -2,9 +2,11 @@
from typing import Any
-import attr
-
-from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.components.diagnostics import (
+ async_redact_data,
+ device_entry_as_dict,
+ entity_entry_as_dict,
+)
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics(
return data
data["device"] = {
- **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV),
+ **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV),
"entities": {},
}
@@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics(
# The context doesn't provide useful information in this case.
state_dict.pop("context", None)
+ entity_dict = entity_entry_as_dict(entity_entry)
+ # The entity_id is already provided at root level (the key).
+ del entity_dict["entity_id"]
data["device"]["entities"][entity_entry.entity_id] = {
- **async_redact_data(
- attr.asdict(
- entity_entry, filter=lambda attr, value: attr.name != "entity_id"
- ),
- TO_REDACT,
- ),
+ **async_redact_data(entity_dict, TO_REDACT),
"state": state_dict,
}
diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py
index 3d7c3ce41386..05f30d960b90 100644
--- a/homeassistant/components/anthropic/__init__.py
+++ b/homeassistant/components/anthropic/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.helpers import (
entity_registry as er,
issue_registry as ir,
)
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import CONF_CHAT_MODEL, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
@@ -137,7 +137,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
# Device and entity registries will set the disabled_by flag to None
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
# config entry, but we want to set it to USER instead,
- device_disabled_by = device.disabled_by
+ device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
and not all_disabled
@@ -147,20 +147,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
disabled_by=device_disabled_by,
new_identifiers={(DOMAIN, subentry.subentry_id)},
- add_config_subentry_id=subentry.subentry_id,
- add_config_entry_id=parent_entry.entry_id,
+ new_config_entry_id=parent_entry.entry_id,
+ new_config_subentry_id=subentry.subentry_id,
)
- if parent_entry.entry_id != entry.entry_id:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- )
- else:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- remove_config_subentry_id=None,
- )
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py
index 48705f909e58..ee49b60c7874 100644
--- a/homeassistant/components/anthropic/config_flow.py
+++ b/homeassistant/components/anthropic/config_flow.py
@@ -19,12 +19,11 @@ from homeassistant.config_entries import (
SubentryFlowResult,
)
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_API_KEY,
CONF_LLM_HASS_API,
CONF_NAME,
CONF_PROMPT,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
@@ -569,8 +568,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
{
"role": "user",
"content": "Where are the following coordinates located: "
- f"({zone_home.attributes[ATTR_LATITUDE]},"
- f" {zone_home.attributes[ATTR_LONGITUDE]})?",
+ f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
+ f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
}
],
max_tokens=cast(int, DEFAULT[CONF_MAX_TOKENS]),
diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py
index 4a4ffb86554d..df503af7b46e 100644
--- a/homeassistant/components/anthropic/entity.py
+++ b/homeassistant/components/anthropic/entity.py
@@ -233,8 +233,9 @@ def _convert_content( # noqa: C901
"""Transform HA chat_log content into Anthropic API format."""
messages: list[MessageParam] = []
container_id: str | None = None
+ contents = list(chat_content)
- for content in chat_content:
+ for index, content in enumerate(contents):
if isinstance(content, conversation.ToolResultContent):
external_tool = True
if content.tool_name == "web_search":
@@ -322,14 +323,26 @@ def _convert_content( # noqa: C901
else:
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
elif isinstance(content, conversation.UserContent):
+ has_text = bool(content.content.strip())
+ # Attachments are only appended to the last message afterwards, so
+ # an empty message is only useful for attachments if it is last
+ has_attachments = bool(content.attachments) and index == len(contents) - 1
+ if not has_text and not has_attachments:
+ # The API rejects whitespace-only text blocks and empty
+ # messages, so drop content that carries neither text nor
+ # usable attachments
+ continue
# Combine consequent user messages
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
- content=content.content,
+ content=content.content if has_text else [],
)
)
+ elif not has_text:
+ # Attachments are appended to the last user message later
+ continue
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
@@ -375,7 +388,7 @@ def _convert_content( # noqa: C901
):
container_id = content.native.container.id
- if content.content:
+ if content.content and content.content.strip():
current_index = 0
for detail in (
content.native.citation_details
@@ -455,7 +468,11 @@ def _convert_content( # noqa: C901
]
)
- if (
+ if not messages[-1]["content"]:
+ # Drop assistant messages that ended up without any content
+ # (e.g. whitespace-only text): the API rejects empty messages
+ messages.pop()
+ elif (
isinstance(messages[-1]["content"], list)
and len(messages[-1]["content"]) == 1
and messages[-1]["content"][0]["type"] == "text"
diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json
index 3153ab89fb31..398f3ccef507 100644
--- a/homeassistant/components/anthropic/manifest.json
+++ b/homeassistant/components/anthropic/manifest.json
@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "gold",
+ "quality_scale": "platinum",
"requirements": ["anthropic==0.108.0"]
}
diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py
index ded5f8e57be6..39b74b3fe3b1 100644
--- a/homeassistant/components/api/__init__.py
+++ b/homeassistant/components/api/__init__.py
@@ -46,6 +46,7 @@ from homeassistant.exceptions import (
Unauthorized,
)
from homeassistant.helpers import config_validation as cv, recorder, template
+from homeassistant.helpers.http import MIN_COMPRESSED_RESPONSE_SIZE
from homeassistant.helpers.json import json_dumps, json_fragment
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers.typing import ConfigType
@@ -223,12 +224,14 @@ class APIStatesView(HomeAssistantView):
for state in hass.states.async_all()
if entity_perm(state.entity_id, POLICY_READ)
)
+ body = b"".join((b"[", b",".join(states), b"]"))
response = web.Response(
- body=b"".join((b"[", b",".join(states), b"]")),
+ body=body,
content_type=CONTENT_TYPE_JSON,
zlib_executor_size=32768,
)
- response.enable_compression()
+ if len(body) > MIN_COMPRESSED_RESPONSE_SIZE:
+ response.enable_compression()
return response
@@ -297,11 +300,12 @@ class APIEntityStateView(HomeAssistantView):
return self.json_message(
"Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR
)
- resp = self.json(state.as_dict(), status_code)
-
- resp.headers.add("Location", f"/api/states/{entity_id}")
-
- return resp
+ return web.Response(
+ body=state.as_dict_json,
+ content_type=CONTENT_TYPE_JSON,
+ status=status_code,
+ headers={"Location": f"/api/states/{entity_id}"},
+ )
@ha.callback
def delete(self, request: web.Request, entity_id: str) -> web.Response:
diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py
index 0a39f0ab6f47..d905b3214bde 100644
--- a/homeassistant/components/apple_tv/media_player.py
+++ b/homeassistant/components/apple_tv/media_player.py
@@ -204,7 +204,7 @@ class AppleTvMediaPlayer(
return MediaPlayerState.PLAYING
if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped):
return MediaPlayerState.PAUSED
- return MediaPlayerState.IDLE # Bad or unknown state?
+ return MediaPlayerState.IDLE # type: ignore[unreachable] # Bad or unknown state?
return None
@callback
diff --git a/homeassistant/components/aqualogic/__init__.py b/homeassistant/components/aqualogic/__init__.py
index 1c0233ecfb65..016dd3b4ca54 100644
--- a/homeassistant/components/aqualogic/__init__.py
+++ b/homeassistant/components/aqualogic/__init__.py
@@ -1,5 +1,6 @@
"""Support for AquaLogic devices."""
+import contextlib
from datetime import timedelta
import logging
import threading
@@ -9,22 +10,19 @@ from typing import override
from aqualogic.core import AquaLogic
import voluptuous as vol
-from homeassistant.const import (
- CONF_HOST,
- CONF_PORT,
- EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP,
-)
-from homeassistant.core import Event, HomeAssistant
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
+from .const import DOMAIN, PLATFORMS, UPDATE_TOPIC
+
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "aqualogic"
-UPDATE_TOPIC = f"{DOMAIN}_update"
-CONF_UNIT = "unit"
RECONNECT_INTERVAL = timedelta(seconds=10)
CONFIG_SCHEMA = vol.Schema(
@@ -36,19 +34,84 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
+type AquaLogicConfigEntry = ConfigEntry[AquaLogicProcessor]
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up AquaLogic platform."""
- host = config[DOMAIN][CONF_HOST]
- port = config[DOMAIN][CONF_PORT]
- processor = AquaLogicProcessor(hass, host, port)
- hass.data[DOMAIN] = processor
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, processor.start_listen)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, processor.shutdown)
- _LOGGER.debug("AquaLogicProcessor %s:%i initialized", host, port)
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the AquaLogic component."""
+ if DOMAIN not in config:
+ return True
+
+ hass.async_create_task(_async_import(hass, config[DOMAIN]))
return True
+async def _async_import(hass: HomeAssistant, conf: dict) -> None:
+ """Import AquaLogic configuration from YAML and surface appropriate issues."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_HOST: conf[CONF_HOST], CONF_PORT: conf[CONF_PORT]},
+ )
+
+ if (
+ result.get("type") is FlowResultType.ABORT
+ and result.get("reason") != "already_configured"
+ ):
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_import_issue_cannot_connect",
+ breaks_in_ha_version="2027.2.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml_import_issue_cannot_connect",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "AquaLogic",
+ },
+ )
+ return
+
+ async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"deprecated_yaml_{DOMAIN}",
+ breaks_in_ha_version="2027.2.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "AquaLogic",
+ },
+ )
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: AquaLogicConfigEntry) -> bool:
+ """Set up AquaLogic from a config entry."""
+ processor = AquaLogicProcessor(hass, entry.data[CONF_HOST], entry.data[CONF_PORT])
+ entry.runtime_data = processor
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ processor.start()
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: AquaLogicConfigEntry) -> bool:
+ """Unload an AquaLogic config entry."""
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ processor = entry.runtime_data
+ processor.shutdown()
+ await hass.async_add_executor_job(lambda: processor.join(timeout=5))
+ if processor.is_alive():
+ _LOGGER.warning("Processor thread did not stop within timeout")
+ return unload_ok
+
+
class AquaLogicProcessor(threading.Thread):
"""AquaLogic event processor thread."""
@@ -59,17 +122,15 @@ class AquaLogicProcessor(threading.Thread):
self._host = host
self._port = port
self._shutdown = False
- self._panel = None
+ self._panel: AquaLogic | None = None
- def start_listen(self, event: Event) -> None:
- """Start event-processing thread."""
- _LOGGER.debug("Event processing thread started")
- self.start()
-
- def shutdown(self, event: Event) -> None:
+ def shutdown(self) -> None:
"""Signal shutdown of processing event."""
_LOGGER.debug("Event processing signaled exit")
self._shutdown = True
+ if (panel := self._panel) is not None and panel._socket is not None: # noqa: SLF001
+ with contextlib.suppress(OSError):
+ panel._socket.close() # noqa: SLF001
def data_changed(self, panel: AquaLogic) -> None:
"""Aqualogic data changed callback."""
@@ -82,13 +143,26 @@ class AquaLogicProcessor(threading.Thread):
while True:
panel = AquaLogic()
self._panel = panel
- panel.connect(self._host, self._port)
- panel.process(self.data_changed)
+ try:
+ panel.connect(self._host, self._port)
+ panel.process(self.data_changed)
+ except OSError:
+ pass
+ except Exception as err:
+ _LOGGER.exception(
+ "Unexpected error in AquaLogic processor: %s",
+ type(err).__name__,
+ )
if self._shutdown:
return
- _LOGGER.error("Connection to %s:%d lost", self._host, self._port)
+ _LOGGER.warning(
+ "Connection to %s:%d lost, retrying in %d seconds",
+ self._host,
+ self._port,
+ int(RECONNECT_INTERVAL.total_seconds()),
+ )
time.sleep(RECONNECT_INTERVAL.total_seconds())
@property
diff --git a/homeassistant/components/aqualogic/config_flow.py b/homeassistant/components/aqualogic/config_flow.py
new file mode 100644
index 000000000000..9e7b3f893e0d
--- /dev/null
+++ b/homeassistant/components/aqualogic/config_flow.py
@@ -0,0 +1,109 @@
+"""Config flow for AquaLogic."""
+
+import contextlib
+import threading
+from typing import Any, override
+
+from aqualogic.core import AquaLogic
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT): cv.port,
+ }
+)
+
+# Worst case scenario, this covers both a plain socket timeout (READ_TIMEOUT)
+# and an additional frame-scan timeout (another READ_TIMEOUT), plus one second.
+_PROBE_TIMEOUT = AquaLogic.READ_TIMEOUT * 2 + 1
+
+
+class CannotConnect(Exception):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidDevice(Exception):
+ """Error to indicate the device is not an AquaLogic panel."""
+
+
+def _verify_device(host: str, port: int) -> None:
+ """Connect and verify the device is an AquaLogic panel.
+
+ Raises CannotConnect if the host is unreachable.
+ Raises InvalidDevice if no valid AquaLogic data is received within the timeout.
+ """
+ confirmed = threading.Event()
+
+ def _on_data(_: AquaLogic) -> None:
+ confirmed.set()
+
+ panel = AquaLogic()
+ try:
+ panel.connect(host, port)
+ except OSError as err:
+ raise CannotConnect from err
+
+ probe = threading.Thread(target=panel.process, args=(_on_data,), daemon=True)
+ probe.start()
+ try:
+ confirmed.wait(timeout=_PROBE_TIMEOUT)
+ finally:
+ if (sock := panel._socket) is not None: # noqa: SLF001
+ with contextlib.suppress(OSError):
+ sock.close()
+
+ if not confirmed.is_set():
+ raise InvalidDevice
+
+
+class AquaLogicConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for AquaLogic."""
+
+ VERSION = 1
+
+ @override
+ 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:
+ self._async_abort_entries_match(user_input)
+
+ try:
+ await self.hass.async_add_executor_job(
+ _verify_device, user_input[CONF_HOST], user_input[CONF_PORT]
+ )
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidDevice:
+ errors["base"] = "invalid_device"
+ else:
+ return self.async_create_entry(title="AquaLogic", data=user_input)
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
+ """Import AquaLogic config from configuration.yaml."""
+ self._async_abort_entries_match(
+ {CONF_HOST: import_data[CONF_HOST], CONF_PORT: import_data[CONF_PORT]}
+ )
+
+ try:
+ await self.hass.async_add_executor_job(
+ _verify_device, import_data[CONF_HOST], import_data[CONF_PORT]
+ )
+ except CannotConnect, InvalidDevice:
+ return self.async_abort(reason="cannot_connect")
+
+ return self.async_create_entry(title="AquaLogic", data=import_data)
diff --git a/homeassistant/components/aqualogic/const.py b/homeassistant/components/aqualogic/const.py
new file mode 100644
index 000000000000..881dfe960dfb
--- /dev/null
+++ b/homeassistant/components/aqualogic/const.py
@@ -0,0 +1,8 @@
+"""Constants for the AquaLogic integration."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "aqualogic"
+PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
+
+UPDATE_TOPIC = f"{DOMAIN}_update"
diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json
index cc807e4bb198..2165a1f27ef1 100644
--- a/homeassistant/components/aqualogic/manifest.json
+++ b/homeassistant/components/aqualogic/manifest.json
@@ -2,9 +2,10 @@
"domain": "aqualogic",
"name": "AquaLogic",
"codeowners": [],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aqualogic",
+ "integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aqualogic"],
- "quality_scale": "legacy",
"requirements": ["aqualogic==2.6"]
}
diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py
index a3ff7c1d9d90..6f03b9d2b196 100644
--- a/homeassistant/components/aqualogic/sensor.py
+++ b/homeassistant/components/aqualogic/sensor.py
@@ -3,27 +3,18 @@
from dataclasses import dataclass
from typing import override
-import voluptuous as vol
-
from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.const import (
- CONF_MONITORED_CONDITIONS,
- PERCENTAGE,
- UnitOfPower,
- UnitOfTemperature,
-)
+from homeassistant.const import PERCENTAGE, UnitOfPower, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor
+from . import AquaLogicConfigEntry, AquaLogicProcessor
+from .const import UPDATE_TOPIC
@dataclass(frozen=True)
@@ -101,34 +92,18 @@ SENSOR_TYPES: tuple[AquaLogicSensorEntityDescription, ...] = (
),
)
-SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES]
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All(
- cv.ensure_list, [vol.In(SENSOR_KEYS)]
- )
- }
-)
-
-
-async def async_setup_platform(
+async def async_setup_entry(
hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
+ entry: AquaLogicConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Set up the sensor platform."""
- processor: AquaLogicProcessor = hass.data[DOMAIN]
- monitored_conditions = config[CONF_MONITORED_CONDITIONS]
+ """Set up the sensor entities."""
+ processor = entry.runtime_data
- entities = [
- AquaLogicSensor(processor, description)
- for description in SENSOR_TYPES
- if description.key in monitored_conditions
- ]
-
- async_add_entities(entities)
+ async_add_entities(
+ AquaLogicSensor(processor, description) for description in SENSOR_TYPES
+ )
class AquaLogicSensor(SensorEntity):
@@ -172,4 +147,5 @@ class AquaLogicSensor(SensorEntity):
self._attr_native_value = getattr(panel, self.entity_description.key)
self.async_write_ha_state()
else:
- self._attr_native_unit_of_measurement = None
+ self._attr_native_value = None
+ self.async_write_ha_state()
diff --git a/homeassistant/components/aqualogic/strings.json b/homeassistant/components/aqualogic/strings.json
new file mode 100644
index 000000000000..e8a7414483d4
--- /dev/null
+++ b/homeassistant/components/aqualogic/strings.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_device": "The device at the given address is not an AquaLogic panel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "host": "Hostname or IP address of your AquaLogic controller",
+ "port": "TCP port used to connect to the AquaLogic controller"
+ }
+ }
+ }
+ },
+ "issues": {
+ "deprecated_yaml_import_issue_cannot_connect": {
+ "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your YAML configuration, the AquaLogic device could not be reached or did not respond as an AquaLogic panel. Please ensure the device is accessible and restart Home Assistant to retry, or remove the {domain} key from your configuration and set up the integration via the UI.",
+ "title": "The {integration_title} YAML configuration is being removed"
+ }
+ }
+}
diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py
index 09a3d0c7501b..4524028e15a5 100644
--- a/homeassistant/components/aqualogic/switch.py
+++ b/homeassistant/components/aqualogic/switch.py
@@ -3,55 +3,39 @@
from typing import Any, override
from aqualogic.core import States
-import voluptuous as vol
-from homeassistant.components.switch import (
- PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA,
- SwitchEntity,
-)
-from homeassistant.const import CONF_MONITORED_CONDITIONS
+from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor
+from . import AquaLogicConfigEntry, AquaLogicProcessor
+from .const import UPDATE_TOPIC
-SWITCH_TYPES = {
- "lights": "Lights",
- "filter": "Filter",
- "filter_low_speed": "Filter Low Speed",
- "aux_1": "Aux 1",
- "aux_2": "Aux 2",
- "aux_3": "Aux 3",
- "aux_4": "Aux 4",
- "aux_5": "Aux 5",
- "aux_6": "Aux 6",
- "aux_7": "Aux 7",
+_SWITCH_MAP: dict[str, tuple[str, States]] = {
+ "lights": ("Lights", States.LIGHTS),
+ "filter": ("Filter", States.FILTER),
+ "filter_low_speed": ("Filter Low Speed", States.FILTER_LOW_SPEED),
+ "aux_1": ("Aux 1", States.AUX_1),
+ "aux_2": ("Aux 2", States.AUX_2),
+ "aux_3": ("Aux 3", States.AUX_3),
+ "aux_4": ("Aux 4", States.AUX_4),
+ "aux_5": ("Aux 5", States.AUX_5),
+ "aux_6": ("Aux 6", States.AUX_6),
+ "aux_7": ("Aux 7", States.AUX_7),
}
-PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCH_TYPES)): vol.All(
- cv.ensure_list, [vol.In(SWITCH_TYPES)]
- )
- }
-)
-
-async def async_setup_platform(
+async def async_setup_entry(
hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
+ entry: AquaLogicConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Set up the switch platform."""
- processor: AquaLogicProcessor = hass.data[DOMAIN]
+ """Set up the switch entities."""
+ processor = entry.runtime_data
async_add_entities(
- AquaLogicSwitch(processor, switch_type)
- for switch_type in config[CONF_MONITORED_CONDITIONS]
+ AquaLogicSwitch(processor, switch_type) for switch_type in _SWITCH_MAP
)
@@ -62,20 +46,10 @@ class AquaLogicSwitch(SwitchEntity):
def __init__(self, processor: AquaLogicProcessor, switch_type: str) -> None:
"""Initialize switch."""
+ name, state = _SWITCH_MAP[switch_type]
self._processor = processor
- self._state_name = {
- "lights": States.LIGHTS,
- "filter": States.FILTER,
- "filter_low_speed": States.FILTER_LOW_SPEED,
- "aux_1": States.AUX_1,
- "aux_2": States.AUX_2,
- "aux_3": States.AUX_3,
- "aux_4": States.AUX_4,
- "aux_5": States.AUX_5,
- "aux_6": States.AUX_6,
- "aux_7": States.AUX_7,
- }[switch_type]
- self._attr_name = f"AquaLogic {SWITCH_TYPES[switch_type]}"
+ self._state_name = state
+ self._attr_name = f"AquaLogic {name}"
@property
@override
diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py
index 10a77e3d0204..65f064e29c39 100644
--- a/homeassistant/components/aqvify/coordinator.py
+++ b/homeassistant/components/aqvify/coordinator.py
@@ -201,22 +201,15 @@ class AqvifyAggrDataCoordinator(
self.api_client = api_client
- @staticmethod
- def _get_times() -> tuple[str, str]:
- """Determine strings for time parameters for aggregated data from API."""
- date_time_fmt = "%Y-%m-%dT%H:%MZ"
- base_time = utcnow() - timedelta(hours=1)
- beg_time = base_time.replace(minute=0).strftime(date_time_fmt)
- end_time = base_time.replace(minute=59).strftime(date_time_fmt)
- return beg_time, end_time
-
@override
async def _async_update_data(self) -> dict[str, AqvifyHourAggregatedValues]:
"""Fetch device state."""
devices = self.config_entry.runtime_data.coordinator.data.devices
device_data: dict[str, AqvifyHourAggregatedValues] = {}
- beg_time, end_time = self._get_times()
+ base_time = utcnow() - timedelta(hours=1)
+ beg_time = base_time.replace(minute=0, second=0, microsecond=0)
+ end_time = base_time.replace(minute=59, second=0, microsecond=0)
for device in devices.devices.values():
device_key = device.device_key
if TYPE_CHECKING:
diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index 258d29fceeb4..c7932f7adee9 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -25,7 +25,7 @@ from homeassistant.components import (
wake_word,
websocket_api,
)
-from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
+from homeassistant.const import MATCH_ALL, EntityStateAttribute
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
@@ -1255,7 +1255,7 @@ class PipelineRun:
if (
intent_agent_state := self.hass.states.get(self.intent_agent.id)
) and intent_agent_state.attributes.get(
- ATTR_SUPPORTED_FEATURES, 0
+ EntityStateAttribute.SUPPORTED_FEATURES, 0
) & conversation.ConversationEntityFeature.CONTROL:
intent_filter = _async_local_fallback_intent_filter
diff --git a/homeassistant/components/assist_satellite/llm.py b/homeassistant/components/assist_satellite/llm.py
new file mode 100644
index 000000000000..6590875fe4bc
--- /dev/null
+++ b/homeassistant/components/assist_satellite/llm.py
@@ -0,0 +1,24 @@
+"""LLM tools for the assist_satellite integration."""
+
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return the broadcast LLM tool."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ # assist_satellite registers the broadcast intent when it is set up, and
+ # this platform is only queried once that has happened.
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type == intent.INTENT_BROADCAST
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py
index 7aa6d4d8a7ac..175c35c8297f 100644
--- a/homeassistant/components/asuswrt/diagnostics.py
+++ b/homeassistant/components/asuswrt/diagnostics.py
@@ -2,9 +2,11 @@
from typing import Any
-import attr
-
-from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.components.diagnostics import (
+ async_redact_data,
+ device_entry_as_dict,
+ entity_entry_as_dict,
+)
from homeassistant.const import (
ATTR_CONNECTIONS,
ATTR_IDENTIFIERS,
@@ -39,7 +41,7 @@ async def async_get_config_entry_diagnostics(
return data
data["device"] = {
- **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV),
+ **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV),
"entities": {},
"tracked_devices": [],
}
@@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics(
# The context doesn't provide useful information in this case.
state_dict.pop("context", None)
+ entity_dict = entity_entry_as_dict(entity_entry)
+ # The entity_id is already provided at root level (the key).
+ del entity_dict["entity_id"]
data["device"]["entities"][entity_entry.entity_id] = {
- **async_redact_data(
- attr.asdict(
- entity_entry, filter=lambda attr, value: attr.name != "entity_id"
- ),
- TO_REDACT,
- ),
+ **async_redact_data(entity_dict, TO_REDACT),
"state": state_dict,
}
diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py
index 12fd0806245d..a4cfd3a490be 100644
--- a/homeassistant/components/august/lock.py
+++ b/homeassistant/components/august/lock.py
@@ -8,7 +8,11 @@ from yalexs.activity import ActivityType
from yalexs.lock import Lock, LockOperation, LockStatus
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
-from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
+from homeassistant.components.lock import (
+ LockEntity,
+ LockEntityFeature,
+ LockEntityStateAttribute,
+)
from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -134,7 +138,7 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity):
@override
async def async_added_to_hass(self) -> None:
- """Restore ATTR_CHANGED_BY on startup.
+ """Restore changed_by on startup.
It is likely no longer in the activity log.
"""
@@ -143,5 +147,7 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity):
if not (last_state := await self.async_get_last_state()):
return
- if ATTR_CHANGED_BY in last_state.attributes:
- self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY]
+ if LockEntityStateAttribute.CHANGED_BY in last_state.attributes:
+ self._attr_changed_by = last_state.attributes[
+ LockEntityStateAttribute.CHANGED_BY
+ ]
diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py
index 348b2b8e2e35..740640dc69df 100644
--- a/homeassistant/components/august/sensor.py
+++ b/homeassistant/components/august/sensor.py
@@ -17,10 +17,10 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_ENTITY_PICTURE,
PERCENTAGE,
STATE_UNAVAILABLE,
EntityCategory,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -170,7 +170,7 @@ class AugustOperatorSensor(AugustEntity, RestoreSensor):
@override
async def async_added_to_hass(self) -> None:
- """Restore ATTR_CHANGED_BY on startup.
+ """Restore attributes on startup.
It is likely no longer in the activity log.
"""
@@ -187,8 +187,8 @@ class AugustOperatorSensor(AugustEntity, RestoreSensor):
self._attr_native_value = last_sensor_state.native_value
last_attrs = last_state.attributes
- if ATTR_ENTITY_PICTURE in last_attrs:
- self._attr_entity_picture = last_attrs[ATTR_ENTITY_PICTURE]
+ if EntityStateAttribute.ENTITY_PICTURE in last_attrs:
+ self._attr_entity_picture = last_attrs[EntityStateAttribute.ENTITY_PICTURE]
if ATTR_OPERATION_REMOTE in last_attrs:
self._operated_remote = last_attrs[ATTR_OPERATION_REMOTE]
if ATTR_OPERATION_KEYPAD in last_attrs:
diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py
index a48d704141fc..70b66bf2b82c 100644
--- a/homeassistant/components/aurora/__init__.py
+++ b/homeassistant/components/aurora/__init__.py
@@ -1,4 +1,4 @@
-"""The aurora component."""
+"""The Aurora integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py
index b6fb8df0f7ea..1b485f551f3b 100644
--- a/homeassistant/components/aurora/coordinator.py
+++ b/homeassistant/components/aurora/coordinator.py
@@ -1,4 +1,4 @@
-"""The aurora component."""
+"""The Aurora integration."""
from datetime import timedelta
import logging
diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py
index 317b82aed5a0..4403bdecd3d7 100644
--- a/homeassistant/components/aurora/entity.py
+++ b/homeassistant/components/aurora/entity.py
@@ -1,4 +1,4 @@
-"""The aurora component."""
+"""The Aurora integration."""
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json
index 3f9d4e842ea5..33a1aedc943a 100644
--- a/homeassistant/components/axis/manifest.json
+++ b/homeassistant/components/axis/manifest.json
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
- "requirements": ["axis==72"],
+ "requirements": ["axis==74"],
"ssdp": [
{
"manufacturer": "AXIS"
diff --git a/homeassistant/components/backblaze_b2/quality_scale.yaml b/homeassistant/components/backblaze_b2/quality_scale.yaml
index 1532f08d9a82..ef20474b3130 100644
--- a/homeassistant/components/backblaze_b2/quality_scale.yaml
+++ b/homeassistant/components/backblaze_b2/quality_scale.yaml
@@ -123,8 +123,4 @@ rules:
comment: |
The b2sdk library does not support custom HTTP session injection.
It manages HTTP connections internally through its own session management.
- strict-typing:
- status: exempt
- comment: |
- The b2sdk dependency does not include a py.typed file and is not PEP 561 compliant.
- This is outside the integration's control as it's a third-party library requirement.
+ strict-typing: todo
diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py
index a8807a062add..625b742164ad 100644
--- a/homeassistant/components/bang_olufsen/event.py
+++ b/homeassistant/components/bang_olufsen/event.py
@@ -62,9 +62,7 @@ async def async_setup_entry(
if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in {
remote.serial_number for remote in remotes
}:
- device_registry.async_update_device(
- device.id, remove_config_entry_id=config_entry.entry_id
- )
+ device_registry.async_remove_device(device.id)
async_add_entities(new_entities=entities)
diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py
index ba7c768f24aa..aca1b550eada 100644
--- a/homeassistant/components/blebox/binary_sensor.py
+++ b/homeassistant/components/blebox/binary_sensor.py
@@ -29,6 +29,7 @@ BINARY_SENSOR_TYPES = (
),
BinarySensorEntityDescription(
key="input",
+ translation_key="input",
),
)
diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py
index fd277810369f..16ab7b4493d7 100644
--- a/homeassistant/components/blebox/button.py
+++ b/homeassistant/components/blebox/button.py
@@ -43,8 +43,6 @@ async def async_setup_entry(
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
"""Representation of BleBox buttons."""
- _attr_name = None
-
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
) -> None:
diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json
index 382d6c34ebe0..82f9cb4944f6 100644
--- a/homeassistant/components/blebox/strings.json
+++ b/homeassistant/components/blebox/strings.json
@@ -70,6 +70,16 @@
}
},
"entity": {
+ "binary_sensor": {
+ "input": { "name": "Input" }
+ },
+ "button": {
+ "close": { "name": "Close" },
+ "down": { "name": "Down" },
+ "fav": { "name": "Favorite" },
+ "open": { "name": "Open" },
+ "up": { "name": "Up" }
+ },
"light": { "channel": { "name": "Channel {index}" } },
"sensor": {
"active_power": { "name": "Active power" },
diff --git a/homeassistant/components/blebox/update.py b/homeassistant/components/blebox/update.py
index e7e0088d3290..389b0560c6fe 100644
--- a/homeassistant/components/blebox/update.py
+++ b/homeassistant/components/blebox/update.py
@@ -22,7 +22,7 @@ from .const import DOMAIN
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
-PARALLEL_UPDATES = 0
+PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=1)
diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py
index 95f271764a83..981bff1fff71 100644
--- a/homeassistant/components/blink/alarm_control_panel.py
+++ b/homeassistant/components/blink/alarm_control_panel.py
@@ -11,7 +11,6 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
-from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -43,6 +42,7 @@ class BlinkSyncModuleHA(
):
"""Representation of a Blink Alarm Control Panel."""
+ _attr_attribution = DEFAULT_ATTRIBUTION
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
_attr_code_arm_required = False
_attr_has_entity_name = True
@@ -77,7 +77,6 @@ class BlinkSyncModuleHA(
"""Update attributes for alarm control panel."""
self.sync.attributes["network_info"] = self.api.networks
self.sync.attributes["associated_cameras"] = list(self.sync.cameras)
- self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION
self._attr_extra_state_attributes = self.sync.attributes
self._attr_alarm_state = (
AlarmControlPanelState.ARMED_AWAY
diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json
index f31a6e0afef5..e525e5ae9255 100644
--- a/homeassistant/components/bluetooth/manifest.json
+++ b/homeassistant/components/bluetooth/manifest.json
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.22",
- "habluetooth==6.26.2"
+ "habluetooth==6.26.5"
]
}
diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py
index e5b25e37e290..33a6516792a6 100644
--- a/homeassistant/components/bosch_alarm/entity.py
+++ b/homeassistant/components/bosch_alarm/entity.py
@@ -48,7 +48,7 @@ class BoschAlarmEntity(Entity):
"""Stop observing state changes."""
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
if self._observe_faults:
- self.panel.faults_observer.attach(self.schedule_update_ha_state)
+ self.panel.faults_observer.detach(self.schedule_update_ha_state)
class BoschAlarmAreaEntity(BoschAlarmEntity):
@@ -92,7 +92,7 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
@override
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
- await super().async_added_to_hass()
+ await super().async_will_remove_from_hass()
if self._observe_alarms:
self._area.alarm_observer.detach(self.schedule_update_ha_state)
if self._observe_ready:
@@ -126,7 +126,7 @@ class BoschAlarmPointEntity(BoschAlarmEntity):
@override
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
- await super().async_added_to_hass()
+ await super().async_will_remove_from_hass()
self._point.status_observer.detach(self.schedule_update_ha_state)
@@ -155,7 +155,7 @@ class BoschAlarmDoorEntity(BoschAlarmEntity):
@override
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
- await super().async_added_to_hass()
+ await super().async_will_remove_from_hass()
self._door.status_observer.detach(self.schedule_update_ha_state)
@@ -184,5 +184,5 @@ class BoschAlarmOutputEntity(BoschAlarmEntity):
@override
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
- await super().async_added_to_hass()
+ await super().async_will_remove_from_hass()
self._output.status_observer.detach(self.schedule_update_ha_state)
diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py
index 738d8d187fee..ee3be122bc55 100644
--- a/homeassistant/components/bring/coordinator.py
+++ b/homeassistant/components/bring/coordinator.py
@@ -176,9 +176,7 @@ class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]):
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
- device_reg.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_reg.async_remove_device(device.id)
class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]):
diff --git a/homeassistant/components/bring/services.py b/homeassistant/components/bring/services.py
index 3d5b20e7b6dd..7b84835f4a83 100644
--- a/homeassistant/components/bring/services.py
+++ b/homeassistant/components/bring/services.py
@@ -9,7 +9,7 @@ from bring_api import (
)
import voluptuous as vol
-from homeassistant.components.event import ATTR_EVENT_TYPE
+from homeassistant.components.event import EventEntityStateAttribute
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
@@ -70,7 +70,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
list_uuid = entity.unique_id.split("_")[1]
- activity = state.attributes[ATTR_EVENT_TYPE]
+ activity = state.attributes[EventEntityStateAttribute.EVENT_TYPE]
reaction: ReactionType = call.data[ATTR_REACTION]
diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py
index 859e5f0d292f..ac3a2799f476 100644
--- a/homeassistant/components/broadlink/updater.py
+++ b/homeassistant/components/broadlink/updater.py
@@ -90,6 +90,11 @@ class BroadlinkUpdateManager(ABC, Generic[_ApiT]): # noqa: UP046
self.device.api.model,
self.device.api.host[0],
)
+ if not self.coordinator.last_update_success:
+ # When the previous refresh already failed, the coordinator
+ # will skip listener notification, so notify explicitly to
+ # ensure entities flip to unavailable on this transition.
+ self.coordinator.async_update_listeners()
raise UpdateFailed(err) from err
if self.available is False:
diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py
index 0000fbf09112..6966beb7d3e4 100644
--- a/homeassistant/components/bsblan/__init__.py
+++ b/homeassistant/components/bsblan/__init__.py
@@ -257,10 +257,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# Perform first refresh of fast coordinator (required for entities)
await fast_coordinator.async_config_entry_first_refresh()
- # Refresh slow coordinator - don't fail if DHW is not available
- # This allows the integration to work even if the device doesn't support DHW
- await slow_coordinator.async_refresh()
-
entry.runtime_data = BSBLanData(
client=bsblan,
fast_coordinator=fast_coordinator,
@@ -271,6 +267,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
available_circuits=circuits,
)
+ # Fetch slow data in the background so it does not block startup.
+ entry.async_create_background_task(
+ hass,
+ slow_coordinator.async_refresh(),
+ name=f"{DOMAIN}_slow_data_fetch_{entry.entry_id}",
+ )
+
# Register main device before forwarding platforms, so sub-devices
# (heating circuits, water heater) can reference it via via_device
device_registry = dr.async_get(hass)
diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py
index 094a74f855f1..4f1ea98d1939 100644
--- a/homeassistant/components/bsblan/climate.py
+++ b/homeassistant/components/bsblan/climate.py
@@ -2,7 +2,7 @@
from typing import Any, Final, override
-from bsblan import BSBLANError, State, get_hvac_action_category
+from bsblan import BSBLANError, EntityInfo, State, get_hvac_action_category
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
@@ -54,6 +54,14 @@ BSBLAN_TO_HA_HVAC_MODE: Final[dict[int, HVACMode]] = {
}
+def _resolve_temperature_bound(*sources: EntityInfo[float] | None) -> float | None:
+ """Return the first usable temperature bound from the given sources."""
+ for source in sources:
+ if source is not None and source.value is not None:
+ return source.value
+ return None
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: BSBLanConfigEntry,
@@ -97,12 +105,23 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
else:
self._attr_unique_id = f"{mac}-climate-{circuit}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
- # Set temperature range from per-circuit static data
+ # Set temperature range from per-circuit static data. Standard BSB/LPB
+ # circuits expose the bounds via heating_protective_setpoint (714) and
+ # comfort_setpoint_max (716); min_temp/max_temp (15006/15007) exist
+ # only on PPS devices. Inactive parameters ("---") have value None.
if (static := data.static.get(circuit)) is not None:
- if (min_temp := static.min_temp) is not None and min_temp.value is not None:
- self._attr_min_temp = min_temp.value
- if (max_temp := static.max_temp) is not None and max_temp.value is not None:
- self._attr_max_temp = max_temp.value
+ if (
+ min_temp := _resolve_temperature_bound(
+ static.heating_protective_setpoint, static.min_temp
+ )
+ ) is not None:
+ self._attr_min_temp = min_temp
+ if (
+ max_temp := _resolve_temperature_bound(
+ static.comfort_setpoint_max, static.max_temp
+ )
+ ) is not None:
+ self._attr_max_temp = max_temp
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property
diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json
index f0adc5f2eb8b..2403b38a5274 100644
--- a/homeassistant/components/bsblan/manifest.json
+++ b/homeassistant/components/bsblan/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
- "requirements": ["python-bsblan==6.1.4"],
+ "requirements": ["python-bsblan==6.1.6"],
"zeroconf": [
{
"name": "bsb-lan*",
diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py
index 315320fd5b83..8f3e46c72a62 100644
--- a/homeassistant/components/bsblan/services.py
+++ b/homeassistant/components/bsblan/services.py
@@ -2,7 +2,7 @@
from datetime import time
import logging
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any, Final
from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
import voluptuous as vol
@@ -29,6 +29,16 @@ ATTR_FRIDAY_SLOTS = "friday_slots"
ATTR_SATURDAY_SLOTS = "saturday_slots"
ATTR_SUNDAY_SLOTS = "sunday_slots"
+_DAY_NAME_SLOT_ATTR_PAIRS: tuple[tuple[str, str], ...] = (
+ ("monday", ATTR_MONDAY_SLOTS),
+ ("tuesday", ATTR_TUESDAY_SLOTS),
+ ("wednesday", ATTR_WEDNESDAY_SLOTS),
+ ("thursday", ATTR_THURSDAY_SLOTS),
+ ("friday", ATTR_FRIDAY_SLOTS),
+ ("saturday", ATTR_SATURDAY_SLOTS),
+ ("sunday", ATTR_SUNDAY_SLOTS),
+)
+
# Schema for a single time slot
_SLOT_SCHEMA = vol.Schema(
@@ -39,16 +49,16 @@ _SLOT_SCHEMA = vol.Schema(
)
+_WEEKLY_SCHEDULE_FIELDS: Final[dict[vol.Marker, Any]] = {
+ vol.Optional(slot_attr): vol.All(cv.ensure_list, [_SLOT_SCHEMA])
+ for _, slot_attr in _DAY_NAME_SLOT_ATTR_PAIRS
+}
+
+
SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
- vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
- vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
- vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
- vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
- vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
- vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
- vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
+ **_WEEKLY_SCHEDULE_FIELDS,
}
)
@@ -98,11 +108,26 @@ def _convert_time_slots_to_day_schedule(
return DaySchedule(slots=time_slots)
-async def set_hot_water_schedule(service_call: ServiceCall) -> None:
- """Set hot water heating schedule."""
- device_id = service_call.data[ATTR_DEVICE_ID]
+def _build_weekly_schedule_days(
+ service_call: ServiceCall,
+) -> dict[str, DaySchedule | None]:
+ """Build day-name -> schedule values from the service call data.
+
+ Days omitted from the service call map to None, which tells python-bsblan not to
+ modify that day.
+ """
+ return {
+ day_name: _convert_time_slots_to_day_schedule(service_call.data.get(attr_name))
+ for day_name, attr_name in _DAY_NAME_SLOT_ATTR_PAIRS
+ }
+
+
+def _resolve_config_entry(
+ service_call: ServiceCall,
+) -> tuple[BSBLanConfigEntry, dr.DeviceEntry]:
+ """Resolve device_id from a service call into a loaded BSBLAN config entry."""
+ device_id: str = service_call.data[ATTR_DEVICE_ID]
- # Get the device and config entry
device_registry = dr.async_get(service_call.hass)
device_entry = device_registry.async_get(device_id)
@@ -137,56 +162,38 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None:
translation_placeholders={"device_name": device_entry.name or device_id},
)
+ return entry, device_entry
+
+
+def _device_name(device_entry: dr.DeviceEntry) -> str:
+ """Return the best available display name for a device."""
+ return device_entry.name_by_user or device_entry.name or device_entry.id
+
+
+def _ensure_water_heater_device(device_entry: dr.DeviceEntry) -> None:
+ """Validate the service targets the water heater sub-device."""
+ for domain, identifier in device_entry.identifiers:
+ if domain == DOMAIN and identifier.endswith("-water-heater"):
+ return
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="not_a_water_heater_device",
+ translation_placeholders={"device_name": _device_name(device_entry)},
+ )
+
+
+async def set_hot_water_schedule(service_call: ServiceCall) -> None:
+ """Set hot water heating schedule."""
+ entry, device_entry = _resolve_config_entry(service_call)
+ _ensure_water_heater_device(device_entry)
client = entry.runtime_data.client
- # Convert time slots to DaySchedule objects
- monday = _convert_time_slots_to_day_schedule(
- service_call.data.get(ATTR_MONDAY_SLOTS)
- )
- tuesday = _convert_time_slots_to_day_schedule(
- service_call.data.get(ATTR_TUESDAY_SLOTS)
- )
- wednesday = _convert_time_slots_to_day_schedule(
- service_call.data.get(ATTR_WEDNESDAY_SLOTS)
- )
- thursday = _convert_time_slots_to_day_schedule(
- service_call.data.get(ATTR_THURSDAY_SLOTS)
- )
- friday = _convert_time_slots_to_day_schedule(
- service_call.data.get(ATTR_FRIDAY_SLOTS)
- )
- saturday = _convert_time_slots_to_day_schedule(
- service_call.data.get(ATTR_SATURDAY_SLOTS)
- )
- sunday = _convert_time_slots_to_day_schedule(
- service_call.data.get(ATTR_SUNDAY_SLOTS)
- )
+ days = _build_weekly_schedule_days(service_call)
+ dhw_schedule = DHWSchedule(**days)
- # Create the DHWSchedule object
- dhw_schedule = DHWSchedule(
- monday=monday,
- tuesday=tuesday,
- wednesday=wednesday,
- thursday=thursday,
- friday=friday,
- saturday=saturday,
- sunday=sunday,
- )
-
- LOGGER.debug(
- "Setting hot water schedule - Monday: %s, Tuesday: %s, Wednesday: %s, "
- "Thursday: %s, Friday: %s, Saturday: %s, Sunday: %s",
- monday,
- tuesday,
- wednesday,
- thursday,
- friday,
- saturday,
- sunday,
- )
+ LOGGER.debug("Setting hot water schedule: %s", dhw_schedule)
try:
- # Call the BSB-LAN API to set the schedule
await client.set_hot_water_schedule(dhw_schedule)
except BSBLANError as err:
raise HomeAssistantError(
@@ -201,45 +208,11 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None:
async def async_sync_time(service_call: ServiceCall) -> None:
"""Synchronize BSB-LAN device time with Home Assistant."""
- device_id: str = service_call.data[ATTR_DEVICE_ID]
-
- # Get the device and config entry
- device_registry = dr.async_get(service_call.hass)
- 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},
- )
-
- # Find the config entry for this device
- matching_entries: list[BSBLanConfigEntry] = [
- entry
- for entry in service_call.hass.config_entries.async_entries(DOMAIN)
- if entry.entry_id in device_entry.config_entries
- ]
-
- if not matching_entries:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="no_config_entry_for_device",
- translation_placeholders={"device_id": device_entry.name or device_id},
- )
-
- entry = matching_entries[0]
-
- # Verify the config entry is loaded
- if entry.state is not ConfigEntryState.LOADED:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="config_entry_not_loaded",
- translation_placeholders={"device_name": device_entry.name or device_id},
- )
-
+ entry, device_entry = _resolve_config_entry(service_call)
client = entry.runtime_data.client
- await async_sync_device_time(client, device_entry.name or device_id)
+ await async_sync_device_time(
+ client, device_entry.name or service_call.data[ATTR_DEVICE_ID]
+ )
SYNC_TIME_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/bsblan/services.yaml b/homeassistant/components/bsblan/services.yaml
index 0844aa35feaa..ccfb91594aa1 100644
--- a/homeassistant/components/bsblan/services.yaml
+++ b/homeassistant/components/bsblan/services.yaml
@@ -6,6 +6,8 @@ sync_time:
selector:
device:
integration: bsblan
+ entity:
+ - domain: button
set_hot_water_schedule:
fields:
@@ -15,6 +17,8 @@ set_hot_water_schedule:
selector:
device:
integration: bsblan
+ entity:
+ - domain: water_heater
monday_slots:
selector:
object:
diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json
index 425bfd65e97a..e97561661049 100644
--- a/homeassistant/components/bsblan/strings.json
+++ b/homeassistant/components/bsblan/strings.json
@@ -127,6 +127,9 @@
"no_config_entry_for_device": {
"message": "No configuration entry found for device: {device_id}"
},
+ "not_a_water_heater_device": {
+ "message": "The selected device ({device_name}) is not a water heater. Please select the water heater sub-device."
+ },
"set_data_error": {
"message": "An error occurred while sending the data to the BSB-LAN device"
},
diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py
index 518b6e4dcc45..8c0f8b37cdf1 100644
--- a/homeassistant/components/bsblan/water_heater.py
+++ b/homeassistant/components/bsblan/water_heater.py
@@ -80,32 +80,49 @@ class BSBLANWaterHeater(BSBLanWaterHeaterDeviceEntity, WaterHeaterEntity):
# Initialize available attribute to resolve multiple inheritance conflict
self._attr_available = True
- # Set temperature limits based on device capabilities from slow coordinator
+ @property
+ @override
+ def min_temp(self) -> float:
+ """Return the minimum temperature.
+
+ Derived from the slow-coordinator DHW config, which may still be
+ pending when the platform is set up. Falls back to the default until
+ the config becomes available.
+ """
dhw_config = (
- data.slow_coordinator.data.dhw_config
- if data.slow_coordinator.data
+ self.slow_coordinator.data.dhw_config
+ if self.slow_coordinator.data
else None
)
-
- # For min_temp: Use reduced_setpoint from config data (slow polling)
if (
dhw_config is not None
and dhw_config.reduced_setpoint is not None
and dhw_config.reduced_setpoint.value is not None
):
- self._attr_min_temp = dhw_config.reduced_setpoint.value
- else:
- self._attr_min_temp = 10.0 # Default minimum
+ return dhw_config.reduced_setpoint.value
+ return 10.0 # Default minimum
- # For max_temp: Use nominal_setpoint_max from config data (slow polling)
+ @property
+ @override
+ def max_temp(self) -> float:
+ """Return the maximum temperature.
+
+ Derived from the slow-coordinator DHW config, which may still be
+ pending when the platform is set up. Falls back to the default until
+ the config becomes available.
+ """
+ dhw_config = (
+ self.slow_coordinator.data.dhw_config
+ if self.slow_coordinator.data
+ else None
+ )
if (
dhw_config is not None
and dhw_config.nominal_setpoint_max is not None
and dhw_config.nominal_setpoint_max.value is not None
):
- self._attr_max_temp = dhw_config.nominal_setpoint_max.value
- else:
- self._attr_max_temp = 65.0 # Default maximum
+ return dhw_config.nominal_setpoint_max.value
+ return 65.0 # Default maximum
@property
def _dhw(self) -> HotWaterState:
diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py
index 1f575d26aeab..4850a83af4b4 100644
--- a/homeassistant/components/buienradar/sensor.py
+++ b/homeassistant/components/buienradar/sensor.py
@@ -28,7 +28,6 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_ATTRIBUTION,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
@@ -905,17 +904,15 @@ class BrSensor(SensorEntity):
# update all other sensors
self._attr_native_value = data.get(sensor_type)
+ self._attr_attribution = data.get(ATTRIBUTION)
if sensor_type.startswith(PRECIPITATION_FORECAST):
- result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)}
+ result = {}
if self._timeframe is not None:
result[TIMEFRAME_LABEL] = f"{self._timeframe} min"
self._attr_extra_state_attributes = result
- result = {
- ATTR_ATTRIBUTION: data.get(ATTRIBUTION),
- STATIONNAME_LABEL: data.get(STATIONNAME),
- }
+ result = {STATIONNAME_LABEL: data.get(STATIONNAME)}
if self._measured is not None:
# convert datetime (Europe/Amsterdam) into local datetime
local_dt = dt_util.as_local(self._measured)
diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py
index 2f91d76aba9a..b64b7fb8e734 100644
--- a/homeassistant/components/caldav/api.py
+++ b/homeassistant/components/caldav/api.py
@@ -1,4 +1,5 @@
"""Library for working with CalDAV api."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
diff --git a/homeassistant/components/calendar/llm.py b/homeassistant/components/calendar/llm.py
new file mode 100644
index 000000000000..d92466377f58
--- /dev/null
+++ b/homeassistant/components/calendar/llm.py
@@ -0,0 +1,106 @@
+"""LLM tools for the calendar integration."""
+
+from datetime import timedelta
+from operator import attrgetter
+from typing import cast, override
+
+import voluptuous as vol
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er, intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput
+from homeassistant.util import dt as dt_util
+from homeassistant.util.json import JsonObjectType
+
+from . import SERVICE_GET_EVENTS
+from .const import DOMAIN
+
+
+class CalendarGetEventsTool(Tool):
+ """LLM Tool allowing querying a calendar."""
+
+ name = "calendar_get_events"
+ description = (
+ "Get events from a calendar. "
+ "When asked if something happens, search the whole week. "
+ "Results are RFC 5545 which means 'end' is exclusive."
+ )
+
+ def __init__(self, calendars: list[str]) -> None:
+ """Init the get events tool."""
+ self.parameters = vol.Schema(
+ {
+ vol.Required("calendar"): vol.In(calendars),
+ vol.Required("range"): vol.In(["today", "week"]),
+ }
+ )
+
+ @override
+ async def async_call(
+ self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
+ ) -> JsonObjectType:
+ """Query a calendar."""
+ data = self.parameters(tool_input.tool_args)
+ result = intent.async_match_targets(
+ hass,
+ intent.MatchTargetsConstraints(
+ name=data["calendar"],
+ domains=[DOMAIN],
+ assistant=llm_context.assistant,
+ ),
+ )
+ if not result.is_match:
+ return {"success": False, "error": "Calendar not found"}
+
+ entity_id = result.states[0].entity_id
+ if data["range"] == "today":
+ start = dt_util.now()
+ end = dt_util.start_of_local_day() + timedelta(days=1)
+ elif data["range"] == "week":
+ start = dt_util.now()
+ end = dt_util.start_of_local_day() + timedelta(days=7)
+
+ service_data = {
+ "entity_id": entity_id,
+ "start_date_time": start.isoformat(),
+ "end_date_time": end.isoformat(),
+ }
+
+ service_result = await hass.services.async_call(
+ DOMAIN,
+ SERVICE_GET_EVENTS,
+ service_data,
+ context=llm_context.context,
+ blocking=True,
+ return_response=True,
+ )
+
+ events = [
+ event if "T" in event["start"] else {**event, "all_day": True}
+ for event in cast(dict, service_result)[entity_id]["events"]
+ ]
+
+ return {"success": True, "result": events}
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return the calendar LLM tools when a calendar is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ entity_registry = er.async_get(hass)
+ names: list[str] = []
+ for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")):
+ if not async_should_expose(hass, llm_context.assistant, state.entity_id):
+ continue
+ entity_entry = entity_registry.async_get(state.entity_id)
+ names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state))
+
+ if not names:
+ return None
+ return LLMTools(tools=[CalendarGetEventsTool(names)])
diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py
index 23315a57c662..c789884d5b4c 100644
--- a/homeassistant/components/ccm15/climate.py
+++ b/homeassistant/components/ccm15/climate.py
@@ -160,26 +160,32 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
+ data = self.data
+ assert data is not None
await self.coordinator.async_set_temperature(
- self._ac_index, self.data, temperature, kwargs.get(ATTR_HVAC_MODE)
+ self._ac_index, data, temperature, kwargs.get(ATTR_HVAC_MODE)
)
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the hvac mode."""
- await self.coordinator.async_set_hvac_mode(self._ac_index, self.data, hvac_mode)
+ data = self.data
+ assert data is not None
+ await self.coordinator.async_set_hvac_mode(self._ac_index, data, hvac_mode)
@override
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
- await self.coordinator.async_set_fan_mode(self._ac_index, self.data, fan_mode)
+ data = self.data
+ assert data is not None
+ await self.coordinator.async_set_fan_mode(self._ac_index, data, fan_mode)
@override
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set the swing mode."""
- await self.coordinator.async_set_swing_mode(
- self._ac_index, self.data, swing_mode
- )
+ data = self.data
+ assert data is not None
+ await self.coordinator.async_set_swing_mode(self._ac_index, data, swing_mode)
@override
async def async_turn_off(self) -> None:
diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json
index 53948217186b..bc11c2924aa1 100644
--- a/homeassistant/components/ccm15/manifest.json
+++ b/homeassistant/components/ccm15/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
- "requirements": ["py_ccm15==1.0.0"]
+ "requirements": ["py_ccm15==1.1.2"]
}
diff --git a/homeassistant/components/cielo_home/climate.py b/homeassistant/components/cielo_home/climate.py
index 38ddd9cbf2df..37d315c108ef 100644
--- a/homeassistant/components/cielo_home/climate.py
+++ b/homeassistant/components/cielo_home/climate.py
@@ -13,7 +13,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -103,28 +103,6 @@ class CieloClimate(CieloDeviceEntity, ClimateEntity):
super().__init__(coordinator, device_id)
self._attr_unique_id = device_id
- @property
- @override
- def temperature_unit(self) -> str:
- """Return the unit of temperature in Home Assistant format.
-
- It can change over time based on the device settings,
- so we fetch it dynamically from the client.
- """
- unit = self.client.temperature_unit()
-
- if not unit:
- return UnitOfTemperature.CELSIUS
-
- normalized = unit.strip().lower()
-
- if normalized in {"c", "°c", "celsius"}:
- return UnitOfTemperature.CELSIUS
- if normalized in {"f", "°f", "fahrenheit"}:
- return UnitOfTemperature.FAHRENHEIT
-
- return UnitOfTemperature.CELSIUS
-
@property
@override
def supported_features(self) -> ClimateEntityFeature:
diff --git a/homeassistant/components/cielo_home/const.py b/homeassistant/components/cielo_home/const.py
index dbc3d68d342a..1818f472b5c0 100644
--- a/homeassistant/components/cielo_home/const.py
+++ b/homeassistant/components/cielo_home/const.py
@@ -11,12 +11,16 @@ from homeassistant.const import Platform
DOMAIN: Final = "cielo_home"
PLATFORMS: Final[list[Platform]] = [
Platform.CLIMATE,
+ Platform.SENSOR,
]
DEFAULT_NAME: Final = "Cielo Home"
DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60
TIMEOUT: Final[int] = 20
LOGGER: Final = logging.getLogger(__package__)
+SENSOR_TEMPERATURE: Final = "temperature"
+SENSOR_HUMIDITY: Final = "humidity"
+
CIELO_ERRORS: Final[tuple] = (
ClientError,
TimeoutError,
diff --git a/homeassistant/components/cielo_home/entity.py b/homeassistant/components/cielo_home/entity.py
index dfe2e9760440..fdceea79f40f 100644
--- a/homeassistant/components/cielo_home/entity.py
+++ b/homeassistant/components/cielo_home/entity.py
@@ -5,6 +5,7 @@ from typing import override
from cieloconnectapi.device import CieloDeviceAPI
from cieloconnectapi.model import CieloDevice
+from homeassistant.const import UnitOfTemperature
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -12,6 +13,26 @@ from .const import DOMAIN
from .coordinator import CieloDataUpdateCoordinator
+def normalize_temp_unit(client: CieloDeviceAPI) -> str:
+ """Normalize a raw device temperature unit to a UnitOfTemperature value.
+
+ Unrecognized or empty values fall back to Celsius.
+ """
+ unit = client.temperature_unit()
+
+ if not unit:
+ return UnitOfTemperature.CELSIUS
+
+ normalized = unit.strip().lower()
+
+ if normalized in {"c", "°c", "celsius"}:
+ return UnitOfTemperature.CELSIUS
+ if normalized in {"f", "°f", "fahrenheit"}:
+ return UnitOfTemperature.FAHRENHEIT
+
+ return UnitOfTemperature.CELSIUS
+
+
class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]):
"""Representation of a Cielo base entity."""
@@ -74,3 +95,14 @@ class CieloDeviceEntity(CieloBaseEntity):
configuration_url="https://home.cielowigle.com/",
suggested_area=device.name,
)
+
+ @property
+ def temperature_unit(self) -> str:
+ """Return the unit of temperature for the device.
+
+ The unit can change over time based on the device settings,
+ so it is fetched dynamically from the client. This dynamic
+ nature means that if a user changes the device's temperature
+ unit, historical statistics may be affected.
+ """
+ return normalize_temp_unit(self.client)
diff --git a/homeassistant/components/cielo_home/sensor.py b/homeassistant/components/cielo_home/sensor.py
new file mode 100644
index 000000000000..b1cfe14e24d9
--- /dev/null
+++ b/homeassistant/components/cielo_home/sensor.py
@@ -0,0 +1,101 @@
+"""Support for Cielo Home sensors."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import override
+
+from cieloconnectapi.device import CieloDeviceAPI
+from cieloconnectapi.model import CieloDevice
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import SENSOR_HUMIDITY, SENSOR_TEMPERATURE
+from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
+from .entity import CieloDeviceEntity, normalize_temp_unit
+
+
+@dataclass(kw_only=True, frozen=True)
+class CieloSensorEntityDescription(SensorEntityDescription):
+ """Describes a Cielo Home sensor entity."""
+
+ value_fn: Callable[[CieloDeviceAPI, CieloDevice | None], float | int | None]
+ unit_fn: Callable[[CieloDeviceAPI], str | None] | None = None
+
+
+SENSOR_DESCRIPTIONS: tuple[CieloSensorEntityDescription, ...] = (
+ CieloSensorEntityDescription(
+ key=SENSOR_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ suggested_display_precision=1,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda client, device_data: client.current_temperature(),
+ # Temperature unit is dynamic; see the native_unit_of_measurement property for limitations.
+ unit_fn=normalize_temp_unit,
+ ),
+ CieloSensorEntityDescription(
+ key=SENSOR_HUMIDITY,
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=PERCENTAGE,
+ value_fn=lambda client, device_data: (
+ device_data.humidity if device_data else None
+ ),
+ ),
+)
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: CieloHomeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Cielo Home sensors."""
+ coordinator = entry.runtime_data
+
+ entities = [
+ CieloSensor(coordinator, device_id, description)
+ for device_id in coordinator.data.parsed
+ for description in SENSOR_DESCRIPTIONS
+ ]
+ async_add_entities(entities)
+
+
+class CieloSensor(CieloDeviceEntity, SensorEntity):
+ """Representation of a Cielo Home sensor."""
+
+ entity_description: CieloSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: CieloDataUpdateCoordinator,
+ device_id: str,
+ entity_description: CieloSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator, device_id)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{device_id}-{entity_description.key}"
+
+ @property
+ @override
+ def native_value(self) -> float | int | None:
+ """Return the native value of the sensor."""
+ return self.entity_description.value_fn(self.client, self.device_data)
+
+ @property
+ @override
+ def native_unit_of_measurement(self) -> str | None:
+ """Return the native unit of measurement."""
+ if self.entity_description.unit_fn is not None:
+ return self.entity_description.unit_fn(self.client)
+ return super().native_unit_of_measurement
diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py
index 94f952b7737a..81d859b2a978 100644
--- a/homeassistant/components/citybikes/sensor.py
+++ b/homeassistant/components/citybikes/sensor.py
@@ -17,13 +17,12 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
APPLICATION_NAME,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
CONF_RADIUS,
EVENT_HOMEASSISTANT_CLOSE,
+ EntityStateAttribute,
UnitOfLength,
__version__,
)
@@ -235,8 +234,8 @@ class CityBikesStation(SensorEntity):
self._attr_native_value = station.free_bikes
self._attr_extra_state_attributes = {
ATTR_UID: station.extra.get(ATTR_UID),
- ATTR_LATITUDE: station.latitude,
- ATTR_LONGITUDE: station.longitude,
+ EntityStateAttribute.LATITUDE: station.latitude,
+ EntityStateAttribute.LONGITUDE: station.longitude,
ATTR_EMPTY_SLOTS: station.empty_slots,
ATTR_FREE_EBIKES: station.extra.get(EXTRA_EBIKES),
ATTR_TIMESTAMP: station.timestamp,
diff --git a/homeassistant/components/climate/llm.py b/homeassistant/components/climate/llm.py
new file mode 100644
index 000000000000..31a8e3f1e5c0
--- /dev/null
+++ b/homeassistant/components/climate/llm.py
@@ -0,0 +1,37 @@
+"""LLM tools for the climate integration."""
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from .const import DOMAIN, INTENT_SET_TEMPERATURE
+
+# Intents owned by this integration that are exposed as LLM tools.
+LLM_INTENTS = (INTENT_SET_TEMPERATURE,)
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return LLM tools for the integration's intents when its domain is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ if not llm_context.assistant:
+ return None
+
+ if not any(
+ async_should_expose(hass, llm_context.assistant, state.entity_id)
+ for state in hass.states.async_all(DOMAIN)
+ ):
+ return None
+
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type in LLM_INTENTS
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
index bb324eea1b21..2dd10c9b59c9 100644
--- a/homeassistant/components/cloud/const.py
+++ b/homeassistant/components/cloud/const.py
@@ -46,6 +46,9 @@ PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
PREF_GOOGLE_CONNECTED = "google_connected"
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
PREF_ENABLE_CLOUD_ICE_SERVERS = "cloud_ice_servers_enabled"
+PREF_ONBOARDED_ITEMS = "onboarded_items"
+PREF_ONBOARDING_POSTPONED_UNTIL = "onboarding_postponed_until"
+ONBOARDING_ITEMS = {"remote", "backup", "voice", "streaming"}
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural")
DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = True
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index fbbfaab73d56..a962c1fb6853 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -4,6 +4,7 @@ import asyncio
from collections.abc import Awaitable, Callable, Coroutine, Mapping
from contextlib import suppress
import dataclasses
+from datetime import timedelta
from functools import wraps
from http import HTTPStatus
import json
@@ -39,6 +40,7 @@ from homeassistant.loader import (
async_get_custom_components,
async_get_loaded_integration,
)
+from homeassistant.util import dt as dt_util
from homeassistant.util.location import async_detect_location_info
from homeassistant.util.package import async_get_installed_packages
@@ -50,6 +52,7 @@ from .const import (
DATA_CLOUD_LOG_HANDLER,
EVENT_CLOUD_EVENT,
LOGIN_MFA_TIMEOUT,
+ ONBOARDING_ITEMS,
PREF_ALEXA_REPORT_STATE,
PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA,
@@ -99,6 +102,8 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_remote_connect)
websocket_api.async_register_command(hass, websocket_remote_disconnect)
websocket_api.async_register_command(hass, websocket_webrtc_ice_servers)
+ websocket_api.async_register_command(hass, websocket_cloud_onboarding_postpone)
+ websocket_api.async_register_command(hass, websocket_cloud_onboarding_complete)
websocket_api.async_register_command(hass, google_assistant_get)
websocket_api.async_register_command(hass, google_assistant_list)
@@ -844,6 +849,48 @@ async def websocket_update_prefs(
connection.send_message(websocket_api.result_message(msg["id"]))
+@websocket_api.require_admin
+@_require_cloud_login
+@websocket_api.websocket_command({vol.Required("type"): "cloud/onboarding/postpone"})
+@websocket_api.async_response
+@_ws_handle_cloud_errors
+async def websocket_cloud_onboarding_postpone(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Handle request to postpone onboarding."""
+ cloud = hass.data[DATA_CLOUD]
+ postponed_until = (dt_util.utcnow() + timedelta(hours=24)).isoformat()
+ await cloud.client.prefs.async_update(onboarding_postponed_until=postponed_until)
+ connection.send_result(msg["id"], await _account_data(hass, cloud))
+
+
+@websocket_api.require_admin
+@_require_cloud_login
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "cloud/onboarding/complete",
+ vol.Required("items"): [vol.In(ONBOARDING_ITEMS)],
+ }
+)
+@websocket_api.async_response
+@_ws_handle_cloud_errors
+async def websocket_cloud_onboarding_complete(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Handle request to complete onboarding items."""
+ cloud = hass.data[DATA_CLOUD]
+ onboarded_items = list(cloud.client.prefs.onboarded_items)
+ new_items = [item for item in msg["items"] if item not in onboarded_items]
+ if new_items:
+ onboarded_items.extend(dict.fromkeys(new_items))
+ await cloud.client.prefs.async_update(onboarded_items=onboarded_items)
+ connection.send_result(msg["id"], await _account_data(hass, cloud))
+
+
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
@@ -930,6 +977,8 @@ async def _account_data(
"google_local_connected": google_config.is_local_connected,
"logged_in": True,
"prefs": client.prefs.as_dict(),
+ "onboarding_completed": client.prefs.onboarding_completed,
+ "onboarding_postponed": client.prefs.onboarding_postponed,
"remote_certificate": certificate,
"remote_certificate_status": remote.certificate_status,
"remote_connected": remote.is_connected,
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 72941c5a5523..bd5f79524ac4 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
- "requirements": ["hass-nabucasa==2.2.0", "openai==2.21.0"],
+ "requirements": ["hass-nabucasa==2.2.0", "openai==2.45.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py
index 3def31727c27..7fcf1fbd7d42 100644
--- a/homeassistant/components/cloud/prefs.py
+++ b/homeassistant/components/cloud/prefs.py
@@ -15,6 +15,7 @@ from homeassistant.components.google_assistant.http import ( # pylint: disable=
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
+from homeassistant.util import dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
from .const import (
@@ -23,6 +24,7 @@ from .const import (
DEFAULT_GOOGLE_REPORT_STATE,
DEFAULT_TTS_DEFAULT_VOICE,
DOMAIN,
+ ONBOARDING_ITEMS,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE,
@@ -41,6 +43,8 @@ from .const import (
PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_GOOGLE_SETTINGS_VERSION,
PREF_INSTANCE_ID,
+ PREF_ONBOARDED_ITEMS,
+ PREF_ONBOARDING_POSTPONED_UNTIL,
PREF_REMOTE_ALLOW_REMOTE_ENABLE,
PREF_REMOTE_DOMAIN,
PREF_TTS_DEFAULT_VOICE,
@@ -175,6 +179,8 @@ class CloudPreferences:
google_settings_version: int | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
remote_domain: str | None | UndefinedType = UNDEFINED,
+ onboarded_items: list[str] | UndefinedType = UNDEFINED,
+ onboarding_postponed_until: str | None | UndefinedType = UNDEFINED,
remote_enabled: bool | UndefinedType = UNDEFINED,
tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED,
) -> None:
@@ -197,6 +203,8 @@ class CloudPreferences:
(PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
(PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
+ (PREF_ONBOARDED_ITEMS, onboarded_items),
+ (PREF_ONBOARDING_POSTPONED_UNTIL, onboarding_postponed_until),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
(PREF_REMOTE_DOMAIN, remote_domain),
(PREF_TTS_DEFAULT_VOICE, tts_default_voice),
@@ -247,6 +255,8 @@ class CloudPreferences:
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
PREF_GOOGLE_REPORT_STATE: self.google_report_state,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
+ PREF_ONBOARDED_ITEMS: self.onboarded_items,
+ PREF_ONBOARDING_POSTPONED_UNTIL: self.onboarding_postponed_until,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
}
@@ -359,6 +369,31 @@ class CloudPreferences:
"""Return the instance ID."""
return self._prefs.get(PREF_INSTANCE_ID)
+ @property
+ def onboarded_items(self) -> list[str]:
+ """Return list of completed onboarding items."""
+ onboarded_items: list[str] = self._prefs.get(PREF_ONBOARDED_ITEMS, [])
+ return onboarded_items
+
+ @property
+ def onboarding_completed(self) -> bool:
+ """Return if all onboarding items are completed."""
+ return ONBOARDING_ITEMS.issubset(self.onboarded_items)
+
+ @property
+ def onboarding_postponed_until(self) -> str | None:
+ """Return the datetime until which onboarding is postponed."""
+ return self._prefs.get(PREF_ONBOARDING_POSTPONED_UNTIL)
+
+ @property
+ def onboarding_postponed(self) -> bool:
+ """Return if onboarding is currently postponed."""
+ if (postponed_until := self.onboarding_postponed_until) is None:
+ return False
+ if (parsed := dt_util.parse_datetime(postponed_until)) is None:
+ return False
+ return parsed > dt_util.utcnow()
+
@property
def tts_default_voice(self) -> tuple[str, str]:
"""Return the default TTS voice.
@@ -430,6 +465,8 @@ class CloudPreferences:
PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(),
PREF_INSTANCE_ID: uuid.uuid4().hex,
PREF_GOOGLE_SECURE_DEVICES_PIN: None,
+ PREF_ONBOARDED_ITEMS: [],
+ PREF_ONBOARDING_POSTPONED_UNTIL: None,
PREF_REMOTE_DOMAIN: None,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: True,
PREF_USERNAME: username,
diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py
index 0f12c8856d30..ef78235ef704 100644
--- a/homeassistant/components/compensation/sensor.py
+++ b/homeassistant/components/compensation/sensor.py
@@ -6,14 +6,12 @@ from typing import Any, override
import numpy as np
from homeassistant.components.sensor import (
- ATTR_STATE_CLASS,
CONF_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorEntity,
+ SensorEntityCapabilityAttribute,
)
from homeassistant.const import (
- ATTR_DEVICE_CLASS,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_ATTRIBUTE,
CONF_DEVICE_CLASS,
CONF_MAXIMUM,
@@ -24,6 +22,7 @@ from homeassistant.const import (
CONF_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import (
Event,
@@ -172,16 +171,18 @@ class CompensationSensor(SensorEntity):
if self.native_unit_of_measurement is None and self._source_attribute is None:
self._attr_native_unit_of_measurement = new_state.attributes.get(
- ATTR_UNIT_OF_MEASUREMENT
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
)
if self._attr_device_class is None and (
- device_class := new_state.attributes.get(ATTR_DEVICE_CLASS)
+ device_class := new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
):
self._attr_device_class = device_class
if self._attr_state_class is None and (
- state_class := new_state.attributes.get(ATTR_STATE_CLASS)
+ state_class := new_state.attributes.get(
+ SensorEntityCapabilityAttribute.STATE_CLASS
+ )
):
self._attr_state_class = state_class
diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py
index 43d398fc2994..94accda30fbd 100644
--- a/homeassistant/components/config/core.py
+++ b/homeassistant/components/config/core.py
@@ -52,7 +52,7 @@ class CheckConfigView(HomeAssistantView):
"type": "config/core/update",
vol.Optional("country"): cv.country,
vol.Optional("currency"): cv.currency,
- vol.Optional("elevation"): int,
+ vol.Optional("elevation"): vol.Coerce(int),
vol.Optional("external_url"): vol.Any(cv.url_no_path, None),
vol.Optional("internal_url"): vol.Any(cv.url_no_path, None),
vol.Optional("language"): cv.language,
diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py
index c8b99ed1d516..149fccc0a84e 100644
--- a/homeassistant/components/configurator/__init__.py
+++ b/homeassistant/components/configurator/__init__.py
@@ -14,7 +14,7 @@ from typing import Any
import voluptuous as vol
-from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import (
HassJob,
HomeAssistant,
@@ -181,8 +181,8 @@ class Configurator:
data = {
ATTR_CONFIGURE_ID: request_id,
ATTR_FIELDS: fields,
- ATTR_FRIENDLY_NAME: name,
- ATTR_ENTITY_PICTURE: entity_picture,
+ EntityStateAttribute.FRIENDLY_NAME: name,
+ EntityStateAttribute.ENTITY_PICTURE: entity_picture,
}
data.update(
diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py
index 5cb8eb0cf61b..e5fe5de9fef7 100644
--- a/homeassistant/components/control4/__init__.py
+++ b/homeassistant/components/control4/__init__.py
@@ -1,7 +1,6 @@
"""The Control4 integration."""
from dataclasses import dataclass
-import json
import logging
from typing import Any
@@ -84,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
config = entry.data
account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session)
try:
- await account.getAccountBearerToken()
+ await account.get_account_bearer_token()
except client_exceptions.ClientError as exception:
_LOGGER.error("Error connecting to Control4 account API: %s", exception)
raise ConfigEntryNotReady(
@@ -103,7 +102,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
controller_unique_id: str = config[CONF_CONTROLLER_UNIQUE_ID]
director_token_dict = await call_c4_api_retry(
- account.getDirectorBearerToken, controller_unique_id
+ account.get_director_bearer_token, controller_unique_id
)
director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
@@ -111,9 +110,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session
)
- controller_href = (await call_c4_api_retry(account.getAccountControllers))["href"]
+ controller_href = (await call_c4_api_retry(account.get_account_controllers))["href"]
director_sw_version = await call_c4_api_retry(
- account.getControllerOSVersion, controller_href
+ account.get_controller_os_version, controller_href
)
_, model, mac_address = controller_unique_id.split("_", 3)
@@ -132,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
# Store all items found on controller for platforms to use
try:
- all_items_raw = await director.getAllItemInfo()
+ director_all_items: list[dict[str, Any]] = await director.get_all_item_info()
except (TimeoutError, client_exceptions.ClientError) as err:
_LOGGER.error(
"Timeout connecting to Control4 controller at %s",
@@ -142,13 +141,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
f"Timeout connecting to Control4 controller at {config[CONF_HOST]}"
) from err
- director_all_items: list[dict[str, Any]] = json.loads(all_items_raw)
-
# Check if OS version is 3 or higher to get UI configuration
ui_configuration: dict[str, Any] | None = None
if int(director_sw_version.split(".")[0]) >= 3:
try:
- ui_config_raw = await director.getUiConfiguration()
+ ui_configuration = await director.get_ui_configuration()
except (TimeoutError, client_exceptions.ClientError) as err:
_LOGGER.error(
"Timeout getting UI configuration from Control4 controller at %s",
@@ -159,8 +156,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
f" Control4 controller at {config[CONF_HOST]}"
) from err
- ui_configuration = json.loads(ui_config_raw)
-
# Load options from config entry
scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py
index 1894498c93ed..764b8f9da14e 100644
--- a/homeassistant/components/control4/climate.py
+++ b/homeassistant/components/control4/climate.py
@@ -370,7 +370,7 @@ class Control4Climate(Control4Entity, ClimateEntity):
"""Set new target HVAC mode."""
c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode]
c4_climate = self._create_api_object()
- await c4_climate.setHvacMode(c4_hvac_mode)
+ await c4_climate.set_hvac_mode(c4_hvac_mode)
await self.coordinator.async_request_refresh()
@override
@@ -385,26 +385,26 @@ class Control4Climate(Control4Entity, ClimateEntity):
if self.hvac_mode == HVACMode.HEAT_COOL:
if low_temp is not None:
if self.temperature_unit == UnitOfTemperature.CELSIUS:
- await c4_climate.setHeatSetpointC(low_temp)
+ await c4_climate.set_heat_setpoint_c(low_temp)
else:
- await c4_climate.setHeatSetpointF(low_temp)
+ await c4_climate.set_heat_setpoint_f(low_temp)
if high_temp is not None:
if self.temperature_unit == UnitOfTemperature.CELSIUS:
- await c4_climate.setCoolSetpointC(high_temp)
+ await c4_climate.set_cool_setpoint_c(high_temp)
else:
- await c4_climate.setCoolSetpointF(high_temp)
+ await c4_climate.set_cool_setpoint_f(high_temp)
# Handle single temperature setpoint
elif temp is not None:
if self.hvac_mode == HVACMode.COOL:
if self.temperature_unit == UnitOfTemperature.CELSIUS:
- await c4_climate.setCoolSetpointC(temp)
+ await c4_climate.set_cool_setpoint_c(temp)
else:
- await c4_climate.setCoolSetpointF(temp)
+ await c4_climate.set_cool_setpoint_f(temp)
elif self.hvac_mode == HVACMode.HEAT:
if self.temperature_unit == UnitOfTemperature.CELSIUS:
- await c4_climate.setHeatSetpointC(temp)
+ await c4_climate.set_heat_setpoint_c(temp)
else:
- await c4_climate.setHeatSetpointF(temp)
+ await c4_climate.set_heat_setpoint_f(temp)
await self.coordinator.async_request_refresh()
@@ -412,5 +412,5 @@ class Control4Climate(Control4Entity, ClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
c4_climate = self._create_api_object()
- await c4_climate.setFanMode(fan_mode.title())
+ await c4_climate.set_fan_mode(fan_mode.title())
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py
index 508161ce7059..74f1c34d8224 100644
--- a/homeassistant/components/control4/config_flow.py
+++ b/homeassistant/components/control4/config_flow.py
@@ -64,13 +64,13 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN):
account_session = aiohttp_client.async_get_clientsession(self.hass)
account = C4Account(username, password, account_session)
try:
- await account.getAccountBearerToken()
+ await account.get_account_bearer_token()
- account_controllers = await account.getAccountControllers()
+ account_controllers = await account.get_account_controllers()
controller_unique_id = account_controllers["controllerCommonName"]
director_bearer_token = (
- await account.getDirectorBearerToken(controller_unique_id)
+ await account.get_director_bearer_token(controller_unique_id)
)["token"]
except BadCredentials, Unauthorized:
errors["base"] = "invalid_auth"
@@ -91,7 +91,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN):
)
director = C4Director(host, director_bearer_token, director_session)
try:
- await director.getAllItemInfo()
+ await director.get_all_item_info()
except Unauthorized:
errors["base"] = "director_auth_failed"
return errors, data, description_placeholders
diff --git a/homeassistant/components/control4/cover.py b/homeassistant/components/control4/cover.py
index dba2efe87768..5b01ed406f1d 100644
--- a/homeassistant/components/control4/cover.py
+++ b/homeassistant/components/control4/cover.py
@@ -225,5 +225,5 @@ class Control4Cover(Control4Entity, CoverEntity):
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
c4_blind = self._create_api_object()
- await c4_blind.setLevelTarget(kwargs[ATTR_POSITION])
+ await c4_blind.set_level_target(kwargs[ATTR_POSITION])
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py
index a26c5f9f4138..bcb1d50e377d 100644
--- a/homeassistant/components/control4/director_utils.py
+++ b/homeassistant/components/control4/director_utils.py
@@ -23,7 +23,7 @@ async def _update_variables_for_config_entry(
) -> dict[int, dict[str, Any]]:
"""Retrieve data from the Control4 director."""
director = entry.runtime_data.director
- data = await director.getAllItemVariableValue(variable_names)
+ data = await director.get_all_item_variable_value(variable_names)
result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict)
for item in data:
result_dict[item["id"]][item["varName"]] = item["value"]
@@ -48,10 +48,10 @@ async def refresh_tokens(hass: HomeAssistant, entry: Control4ConfigEntry):
account_session = aiohttp_client.async_get_clientsession(hass)
account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session)
- await account.getAccountBearerToken()
+ await account.get_account_bearer_token()
controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID]
- director_token_dict = await account.getDirectorBearerToken(controller_unique_id)
+ director_token_dict = await account.get_director_bearer_token(controller_unique_id)
director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
director = C4Director(
diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py
index c51909e2340c..aa40b041ac2c 100644
--- a/homeassistant/components/control4/light.py
+++ b/homeassistant/components/control4/light.py
@@ -115,7 +115,7 @@ async def async_setup_entry(
item_coordinator = non_dimmer_coordinator
else:
director = runtime_data.director
- item_variables = await director.getItemVariables(item_id)
+ item_variables = await director.get_item_variables(item_id)
_LOGGER.warning(
(
"Couldn't get light state data for %s, skipping setup. Available"
@@ -229,10 +229,10 @@ class Control4Light(Control4Entity, LightEntity):
brightness = (kwargs[ATTR_BRIGHTNESS] / 255) * 100
else:
brightness = 100
- await c4_light.rampToLevel(brightness, transition_length)
+ await c4_light.ramp_to_level(brightness, transition_length)
else:
transition_length = 0
- await c4_light.setLevel(100)
+ await c4_light.set_level(100)
if transition_length == 0:
transition_length = 1000
delay_time = (transition_length / 1000) + 0.7
@@ -249,10 +249,10 @@ class Control4Light(Control4Entity, LightEntity):
transition_length = kwargs[ATTR_TRANSITION] * 1000
else:
transition_length = 0
- await c4_light.rampToLevel(0, transition_length)
+ await c4_light.ramp_to_level(0, transition_length)
else:
transition_length = 0
- await c4_light.setLevel(0)
+ await c4_light.set_level(0)
if transition_length == 0:
transition_length = 1500
delay_time = (transition_length / 1000) + 0.7
diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json
index 685ae8bf05a3..865e9353c7a0 100644
--- a/homeassistant/components/control4/manifest.json
+++ b/homeassistant/components/control4/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyControl4"],
- "requirements": ["pyControl4==1.5.0"],
+ "requirements": ["pyControl4==2.0.2"],
"ssdp": [
{
"st": "c4:director"
diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py
index 6faea420eef8..757a84dc6ef6 100644
--- a/homeassistant/components/control4/media_player.py
+++ b/homeassistant/components/control4/media_player.py
@@ -359,9 +359,9 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
if avail_source.name == source:
audio_only = _SourceType.VIDEO not in avail_source.source_type
if audio_only:
- await self._create_api_object().setAudioSource(avail_source.idx)
+ await self._create_api_object().set_audio_source(avail_source.idx)
else:
- await self._create_api_object().setVideoAndAudioSource(
+ await self._create_api_object().set_video_and_audio_source(
avail_source.idx
)
break
@@ -371,50 +371,50 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
@override
async def async_turn_off(self) -> None:
"""Turn off the room."""
- await self._create_api_object().setRoomOff()
+ await self._create_api_object().set_room_off()
await self.coordinator.async_request_refresh()
@override
async def async_mute_volume(self, mute: bool) -> None:
"""Mute the room."""
if mute:
- await self._create_api_object().setMuteOn()
+ await self._create_api_object().set_mute_on()
else:
- await self._create_api_object().setMuteOff()
+ await self._create_api_object().set_mute_off()
await self.coordinator.async_request_refresh()
@override
async def async_set_volume_level(self, volume: float) -> None:
"""Set room volume, 0-1 scale."""
- await self._create_api_object().setVolume(int(volume * 100))
+ await self._create_api_object().set_volume(int(volume * 100))
await self.coordinator.async_request_refresh()
@override
async def async_volume_up(self) -> None:
"""Increase the volume by 1."""
- await self._create_api_object().setIncrementVolume()
+ await self._create_api_object().set_increment_volume()
await self.coordinator.async_request_refresh()
@override
async def async_volume_down(self) -> None:
"""Decrease the volume by 1."""
- await self._create_api_object().setDecrementVolume()
+ await self._create_api_object().set_decrement_volume()
await self.coordinator.async_request_refresh()
@override
async def async_media_pause(self) -> None:
"""Issue a pause command."""
- await self._create_api_object().setPause()
+ await self._create_api_object().set_pause()
await self.coordinator.async_request_refresh()
@override
async def async_media_play(self) -> None:
"""Issue a play command."""
- await self._create_api_object().setPlay()
+ await self._create_api_object().set_play()
await self.coordinator.async_request_refresh()
@override
async def async_media_stop(self) -> None:
"""Issue a stop command."""
- await self._create_api_object().setStop()
+ await self._create_api_object().set_stop()
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 536abf5b3dd5..b5466dfb5f3e 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -2,7 +2,7 @@
"domain": "conversation",
"name": "Conversation",
"codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
- "dependencies": ["http", "intent"],
+ "dependencies": ["http", "intent", "llm"],
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py
index 2f73f7c33d8f..31ac35cb756c 100644
--- a/homeassistant/components/conversation/trigger.py
+++ b/homeassistant/components/conversation/trigger.py
@@ -151,8 +151,7 @@ async def async_attach_trigger(
if isinstance(
automation_result, ScriptRunResult
) and automation_result.conversation_response not in (None, UNDEFINED):
- # mypy does not understand the type narrowing, unclear why
- return automation_result.conversation_response # type: ignore[return-value]
+ return automation_result.conversation_response
# It's important to return None here instead of a string.
#
diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py
index 2b5bf95042f6..28c535d7223d 100644
--- a/homeassistant/components/counter/__init__.py
+++ b/homeassistant/components/counter/__init__.py
@@ -6,7 +6,6 @@ from typing import Any, Self, override
import voluptuous as vol
from homeassistant.const import (
- ATTR_EDITABLE,
CONF_ICON,
CONF_ID,
CONF_MAXIMUM,
@@ -20,6 +19,8 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
+from .const import CounterEntityStateAttribute
+
_LOGGER = logging.getLogger(__name__)
ATTR_INITIAL = "initial"
@@ -205,14 +206,14 @@ class Counter(collection.CollectionEntity, RestoreEntity):
def extra_state_attributes(self) -> dict:
"""Return the state attributes."""
ret = {
- ATTR_EDITABLE: self.editable,
- ATTR_INITIAL: self._config[CONF_INITIAL],
- ATTR_STEP: self._config[CONF_STEP],
+ CounterEntityStateAttribute.EDITABLE: self.editable,
+ CounterEntityStateAttribute.INITIAL: self._config[CONF_INITIAL],
+ CounterEntityStateAttribute.STEP: self._config[CONF_STEP],
}
if self._config[CONF_MINIMUM] is not None:
- ret[CONF_MINIMUM] = self._config[CONF_MINIMUM]
+ ret[CounterEntityStateAttribute.MINIMUM] = self._config[CONF_MINIMUM]
if self._config[CONF_MAXIMUM] is not None:
- ret[CONF_MAXIMUM] = self._config[CONF_MAXIMUM]
+ ret[CounterEntityStateAttribute.MAXIMUM] = self._config[CONF_MAXIMUM]
return ret
@property
diff --git a/homeassistant/components/counter/const.py b/homeassistant/components/counter/const.py
new file mode 100644
index 000000000000..5d7b14e91fdf
--- /dev/null
+++ b/homeassistant/components/counter/const.py
@@ -0,0 +1,13 @@
+"""Constants for the counter integration."""
+
+from enum import StrEnum
+
+
+class CounterEntityStateAttribute(StrEnum):
+ """State attributes for counter entities."""
+
+ EDITABLE = "editable"
+ INITIAL = "initial"
+ STEP = "step"
+ MINIMUM = "minimum"
+ MAXIMUM = "maximum"
diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py
index 30d390e5588c..5423da44e58e 100644
--- a/homeassistant/components/counter/reproduce_state.py
+++ b/homeassistant/components/counter/reproduce_state.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import Context, HomeAssistant, State
-from . import ATTR_MAXIMUM, ATTR_MINIMUM, ATTR_STEP, DOMAIN, SERVICE_SET_VALUE, VALUE
+from . import DOMAIN, SERVICE_SET_VALUE, VALUE
_LOGGER = logging.getLogger(__name__)
@@ -32,25 +32,13 @@ async def _async_reproduce_state(
return
# Return if we are already at the right state.
- if (
- cur_state.state == state.state
- and cur_state.attributes.get(ATTR_MAXIMUM) == state.attributes.get(ATTR_MAXIMUM)
- and cur_state.attributes.get(ATTR_MINIMUM) == state.attributes.get(ATTR_MINIMUM)
- and cur_state.attributes.get(ATTR_STEP) == state.attributes.get(ATTR_STEP)
- ):
+ if cur_state.state == state.state:
return
service_data = {ATTR_ENTITY_ID: state.entity_id, VALUE: state.state}
- service = SERVICE_SET_VALUE
- if ATTR_MAXIMUM in state.attributes:
- service_data[ATTR_MAXIMUM] = state.attributes[ATTR_MAXIMUM]
- if ATTR_MINIMUM in state.attributes:
- service_data[ATTR_MINIMUM] = state.attributes[ATTR_MINIMUM]
- if ATTR_STEP in state.attributes:
- service_data[ATTR_STEP] = state.attributes[ATTR_STEP]
await hass.services.async_call(
- DOMAIN, service, service_data, context=context, blocking=True
+ DOMAIN, SERVICE_SET_VALUE, service_data, context=context, blocking=True
)
diff --git a/homeassistant/components/counter/trigger.py b/homeassistant/components/counter/trigger.py
index 8913b703b901..c5431367b80c 100644
--- a/homeassistant/components/counter/trigger.py
+++ b/homeassistant/components/counter/trigger.py
@@ -2,7 +2,6 @@
from typing import override
-from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
@@ -12,7 +11,8 @@ from homeassistant.helpers.trigger import (
Trigger,
)
-from . import CONF_INITIAL, DOMAIN
+from . import DOMAIN
+from .const import CounterEntityStateAttribute
def _is_integer_state(state: State) -> bool:
@@ -74,7 +74,9 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger):
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
- if (max_value := state.attributes.get(CONF_MAXIMUM)) is None:
+ if (
+ max_value := state.attributes.get(CounterEntityStateAttribute.MAXIMUM)
+ ) is None:
return False
return state.state == str(max_value)
@@ -89,7 +91,9 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger):
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
- if (min_value := state.attributes.get(CONF_MINIMUM)) is None:
+ if (
+ min_value := state.attributes.get(CounterEntityStateAttribute.MINIMUM)
+ ) is None:
return False
return state.state == str(min_value)
@@ -104,7 +108,9 @@ class CounterResetTrigger(CounterValueBaseTrigger):
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
- if (init_state := state.attributes.get(CONF_INITIAL)) is None:
+ if (
+ init_state := state.attributes.get(CounterEntityStateAttribute.INITIAL)
+ ) is None:
return False
return state.state == str(init_state)
diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json
index dfb353b8719e..ade738d2f7f9 100644
--- a/homeassistant/components/daikin/manifest.json
+++ b/homeassistant/components/daikin/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
- "requirements": ["pydaikin==2.18.1"],
+ "requirements": ["pydaikin==2.18.2"],
"zeroconf": ["_dkapi._tcp.local."]
}
diff --git a/homeassistant/components/data_grand_lyon/__init__.py b/homeassistant/components/data_grand_lyon/__init__.py
index 2f33bfe1d4f7..a7ccf285fe3d 100644
--- a/homeassistant/components/data_grand_lyon/__init__.py
+++ b/homeassistant/components/data_grand_lyon/__init__.py
@@ -12,6 +12,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .coordinator import (
DataGrandLyonConfigEntry,
DataGrandLyonData,
+ DataGrandLyonParkAndRideCoordinator,
DataGrandLyonTclCoordinator,
DataGrandLyonVelovCoordinator,
)
@@ -32,13 +33,19 @@ async def async_setup_entry(
tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client)
velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client)
+ park_and_ride_coordinator = DataGrandLyonParkAndRideCoordinator(hass, entry, client)
- coordinators: list[DataUpdateCoordinator] = [tcl_coordinator, velov_coordinator]
+ coordinators: list[DataUpdateCoordinator] = [
+ tcl_coordinator,
+ velov_coordinator,
+ park_and_ride_coordinator,
+ ]
await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators))
entry.runtime_data = DataGrandLyonData(
tcl_coordinator=tcl_coordinator,
velov_coordinator=velov_coordinator,
+ park_and_ride_coordinator=park_and_ride_coordinator,
)
entry.async_on_unload(entry.add_update_listener(async_update_entry))
diff --git a/homeassistant/components/data_grand_lyon/config_flow.py b/homeassistant/components/data_grand_lyon/config_flow.py
index 4cc15287542a..97b1df9f5e5b 100644
--- a/homeassistant/components/data_grand_lyon/config_flow.py
+++ b/homeassistant/components/data_grand_lyon/config_flow.py
@@ -7,8 +7,10 @@ from typing import Any, override
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import (
DataGrandLyonClient,
+ TclParkAndRide,
TclStop,
VelovStation,
+ find_tcl_park_and_ride_by_id,
find_tcl_stop_by_id,
)
import voluptuous as vol
@@ -32,9 +34,11 @@ from homeassistant.helpers.selector import (
from .const import (
CONF_LINE,
+ CONF_PARK_ID,
CONF_STATION_ID,
CONF_STOP_ID,
DOMAIN,
+ SUBENTRY_TYPE_PARK_AND_RIDE,
SUBENTRY_TYPE_STOP,
SUBENTRY_TYPE_VELOV_STATION,
)
@@ -70,6 +74,7 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
return {
SUBENTRY_TYPE_STOP: StopSubentryFlowHandler,
SUBENTRY_TYPE_VELOV_STATION: VelovStationSubentryFlowHandler,
+ SUBENTRY_TYPE_PARK_AND_RIDE: ParkAndRideSubentryFlowHandler,
}
@override
@@ -396,3 +401,81 @@ def _velov_station_label(station: VelovStation) -> str:
label += f" - {station.number}"
return label
+
+
+class ParkAndRideSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle a subentry flow for adding a TCL park-and-ride (P+R)."""
+
+ def __init__(self) -> None:
+ """Initialize the flow."""
+ self._parks: list[TclParkAndRide] = []
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Pick a park-and-ride from the list fetched from the API, or enter one."""
+ if not self._parks:
+ if error := await self._async_load_park_and_rides():
+ return self.async_abort(reason=error)
+
+ if user_input is not None:
+ park_id = user_input[CONF_PARK_ID]
+ entry = self._get_entry()
+ unique_id = f"park_and_ride_{park_id}"
+
+ for subentry in entry.subentries.values():
+ if subentry.unique_id == unique_id:
+ return self.async_abort(reason="already_configured")
+
+ park = find_tcl_park_and_ride_by_id(self._parks, park_id)
+ return self.async_create_entry(
+ title=park.nom if park else park_id,
+ data={CONF_PARK_ID: park_id},
+ unique_id=unique_id,
+ )
+
+ options = [
+ SelectOptionDict(value=park.id, label=_park_and_ride_label(park))
+ for park in sorted(self._parks, key=lambda p: (p.nom, p.id))
+ ]
+ schema = vol.Schema(
+ {
+ vol.Required(CONF_PARK_ID): SelectSelector(
+ SelectSelectorConfig(
+ options=options,
+ mode=SelectSelectorMode.DROPDOWN,
+ sort=False,
+ custom_value=True,
+ )
+ )
+ }
+ )
+ return self.async_show_form(step_id="user", data_schema=schema)
+
+ async def _async_load_park_and_rides(self) -> str | None:
+ """Fetch park-and-rides from the API, returning an error key on failure."""
+ entry = self._get_entry()
+ session = async_get_clientsession(self.hass)
+ client = DataGrandLyonClient(
+ session=session,
+ username=entry.data[CONF_USERNAME],
+ password=entry.data[CONF_PASSWORD],
+ )
+ try:
+ self._parks = await client.get_tcl_park_and_rides()
+ except ClientResponseError as err:
+ if err.status in (401, 403):
+ return "invalid_auth"
+ return "cannot_connect"
+ except ClientError, TimeoutError:
+ return "cannot_connect"
+ except Exception:
+ _LOGGER.exception(
+ "Unexpected error fetching Data Grand Lyon park-and-rides"
+ )
+ return "unknown"
+ return None
+
+
+def _park_and_ride_label(park: TclParkAndRide) -> str:
+ return f"{park.nom} - {park.id}"
diff --git a/homeassistant/components/data_grand_lyon/const.py b/homeassistant/components/data_grand_lyon/const.py
index 49c66613a8e0..e77e2fcda134 100644
--- a/homeassistant/components/data_grand_lyon/const.py
+++ b/homeassistant/components/data_grand_lyon/const.py
@@ -7,7 +7,9 @@ LOGGER = logging.getLogger(__package__)
SUBENTRY_TYPE_STOP = "stop"
SUBENTRY_TYPE_VELOV_STATION = "velov_station"
+SUBENTRY_TYPE_PARK_AND_RIDE = "park_and_ride"
CONF_LINE = "line"
CONF_STOP_ID = "stop_id"
CONF_STATION_ID = "station_id"
+CONF_PARK_ID = "park_id"
diff --git a/homeassistant/components/data_grand_lyon/coordinator.py b/homeassistant/components/data_grand_lyon/coordinator.py
index e3e25dc71af2..66562a9c23e9 100644
--- a/homeassistant/components/data_grand_lyon/coordinator.py
+++ b/homeassistant/components/data_grand_lyon/coordinator.py
@@ -7,9 +7,11 @@ from typing import override
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import (
DataGrandLyonClient,
+ TclParkAndRide,
TclPassage,
VelovStation,
filter_tcl_passages_by_lines_stops,
+ find_tcl_park_and_ride_by_id,
find_velov_stations_by_ids,
sort_tcl_passages_by_time,
)
@@ -21,10 +23,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
CONF_LINE,
+ CONF_PARK_ID,
CONF_STATION_ID,
CONF_STOP_ID,
DOMAIN,
LOGGER,
+ SUBENTRY_TYPE_PARK_AND_RIDE,
SUBENTRY_TYPE_STOP,
SUBENTRY_TYPE_VELOV_STATION,
)
@@ -36,6 +40,7 @@ class DataGrandLyonData:
tcl_coordinator: DataGrandLyonTclCoordinator
velov_coordinator: DataGrandLyonVelovCoordinator
+ park_and_ride_coordinator: DataGrandLyonParkAndRideCoordinator
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData]
@@ -169,3 +174,66 @@ class DataGrandLyonVelovCoordinator(DataUpdateCoordinator[dict[str, VelovStation
subentry.subentry_id,
)
return velov_stations
+
+
+class DataGrandLyonParkAndRideCoordinator(
+ DataUpdateCoordinator[dict[str, TclParkAndRide]]
+):
+ """Coordinator for TCL park-and-ride (P+R) facilities."""
+
+ config_entry: DataGrandLyonConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: DataGrandLyonConfigEntry,
+ client: DataGrandLyonClient,
+ ) -> None:
+ """Initialize the coordinator."""
+ self.client = client
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=entry,
+ name=f"{DOMAIN}_park_and_ride",
+ update_interval=timedelta(minutes=5),
+ )
+
+ @override
+ async def _async_update_data(self) -> dict[str, TclParkAndRide]:
+ """Fetch data for all monitored park-and-ride facilities."""
+ park_subentries = list(
+ self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_PARK_AND_RIDE)
+ )
+ if not park_subentries:
+ return {}
+
+ try:
+ all_parks = await self.client.get_tcl_park_and_rides()
+ except ClientResponseError as err:
+ if err.status in (401, 403):
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed_park_and_ride",
+ ) from err
+ except (ClientError, TimeoutError) as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed_park_and_ride",
+ ) from err
+
+ parks: dict[str, TclParkAndRide] = {}
+ for subentry in park_subentries:
+ park = find_tcl_park_and_ride_by_id(all_parks, subentry.data[CONF_PARK_ID])
+ if park is not None:
+ parks[subentry.subentry_id] = park
+ else:
+ LOGGER.warning(
+ "Park-and-ride not found for subentry %s",
+ subentry.subentry_id,
+ )
+ return parks
diff --git a/homeassistant/components/data_grand_lyon/diagnostics.py b/homeassistant/components/data_grand_lyon/diagnostics.py
index 8e7788e4025d..3907b40a6b75 100644
--- a/homeassistant/components/data_grand_lyon/diagnostics.py
+++ b/homeassistant/components/data_grand_lyon/diagnostics.py
@@ -27,5 +27,9 @@ async def async_get_config_entry_diagnostics(
subentry_id: asdict(station)
for subentry_id, station in entry.runtime_data.velov_coordinator.data.items()
},
+ "park_and_rides": {
+ subentry_id: asdict(park)
+ for subentry_id, park in entry.runtime_data.park_and_ride_coordinator.data.items()
+ },
},
}
diff --git a/homeassistant/components/data_grand_lyon/entity.py b/homeassistant/components/data_grand_lyon/entity.py
index d65bb85fac7f..3f47311dc697 100644
--- a/homeassistant/components/data_grand_lyon/entity.py
+++ b/homeassistant/components/data_grand_lyon/entity.py
@@ -11,7 +11,11 @@ from homeassistant.helpers.update_coordinator import (
)
from .const import DOMAIN
-from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator
+from .coordinator import (
+ DataGrandLyonParkAndRideCoordinator,
+ DataGrandLyonTclCoordinator,
+ DataGrandLyonVelovCoordinator,
+)
class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator](
@@ -75,3 +79,18 @@ class DataGrandLyonVelovEntity(DataGrandLyonEntity[DataGrandLyonVelovCoordinator
) -> None:
"""Initialize the Vélo'v entity."""
super().__init__(coordinator, subentry, description, "JCDecaux", "Station")
+
+
+class DataGrandLyonParkAndRideEntity(
+ DataGrandLyonEntity[DataGrandLyonParkAndRideCoordinator]
+):
+ """Base entity for Data Grand Lyon park-and-ride facilities."""
+
+ def __init__(
+ self,
+ coordinator: DataGrandLyonParkAndRideCoordinator,
+ subentry: ConfigSubentry,
+ description: EntityDescription,
+ ) -> None:
+ """Initialize the park-and-ride entity."""
+ super().__init__(coordinator, subentry, description, "TCL", "Park & Ride")
diff --git a/homeassistant/components/data_grand_lyon/icons.json b/homeassistant/components/data_grand_lyon/icons.json
index 893b337880de..b8351d150a72 100644
--- a/homeassistant/components/data_grand_lyon/icons.json
+++ b/homeassistant/components/data_grand_lyon/icons.json
@@ -9,6 +9,9 @@
}
},
"sensor": {
+ "accessible_spaces": {
+ "default": "mdi:wheelchair-accessibility"
+ },
"available_bikes": {
"default": "mdi:bike"
},
@@ -18,6 +21,9 @@
"available_mechanical_bikes": {
"default": "mdi:bike"
},
+ "available_spaces": {
+ "default": "mdi:parking"
+ },
"available_stands": {
"default": "mdi:parking"
},
@@ -65,6 +71,9 @@
"state": {
"estimated": "mdi:clock-check-outline"
}
+ },
+ "park_and_ride_capacity": {
+ "default": "mdi:counter"
}
}
}
diff --git a/homeassistant/components/data_grand_lyon/sensor.py b/homeassistant/components/data_grand_lyon/sensor.py
index 0da6e49a7264..4e72d87c0733 100644
--- a/homeassistant/components/data_grand_lyon/sensor.py
+++ b/homeassistant/components/data_grand_lyon/sensor.py
@@ -6,20 +6,29 @@ from datetime import datetime
from typing import override
from zoneinfo import ZoneInfo
-from data_grand_lyon_ha import TclPassage, TclPassageType, VelovStation
+from data_grand_lyon_ha import TclParkAndRide, TclPassage, TclPassageType, VelovStation
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .const import SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION
+from .const import (
+ SUBENTRY_TYPE_PARK_AND_RIDE,
+ SUBENTRY_TYPE_STOP,
+ SUBENTRY_TYPE_VELOV_STATION,
+)
from .coordinator import DataGrandLyonConfigEntry
-from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity
+from .entity import (
+ DataGrandLyonParkAndRideEntity,
+ DataGrandLyonTclEntity,
+ DataGrandLyonVelovEntity,
+)
PARALLEL_UPDATES = 0
@@ -164,6 +173,36 @@ VELOV_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonVelovSensorEntityDescription, ...]
)
+@dataclass(frozen=True, kw_only=True)
+class DataGrandLyonParkAndRideSensorEntityDescription(SensorEntityDescription):
+ """Describes a Data Grand Lyon park-and-ride sensor entity."""
+
+ value_fn: Callable[[TclParkAndRide], StateType]
+
+
+PARK_AND_RIDE_SENSOR_DESCRIPTIONS: tuple[
+ DataGrandLyonParkAndRideSensorEntityDescription, ...
+] = (
+ DataGrandLyonParkAndRideSensorEntityDescription(
+ key="available_spaces",
+ translation_key="available_spaces",
+ value_fn=lambda p: p.nb_tot_place_dispo,
+ ),
+ DataGrandLyonParkAndRideSensorEntityDescription(
+ key="capacity",
+ translation_key="park_and_ride_capacity",
+ value_fn=lambda p: p.capacite,
+ ),
+ DataGrandLyonParkAndRideSensorEntityDescription(
+ key="accessible_spaces",
+ translation_key="accessible_spaces",
+ value_fn=lambda p: p.place_handi,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: DataGrandLyonConfigEntry,
@@ -172,6 +211,7 @@ async def async_setup_entry(
"""Set up Data Grand Lyon sensor entities."""
tcl_coordinator = entry.runtime_data.tcl_coordinator
velov_coordinator = entry.runtime_data.velov_coordinator
+ park_and_ride_coordinator = entry.runtime_data.park_and_ride_coordinator
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
async_add_entities(
@@ -191,6 +231,17 @@ async def async_setup_entry(
config_subentry_id=subentry.subentry_id,
)
+ for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PARK_AND_RIDE):
+ async_add_entities(
+ (
+ DataGrandLyonParkAndRideSensor(
+ park_and_ride_coordinator, subentry, description
+ )
+ for description in PARK_AND_RIDE_SENSOR_DESCRIPTIONS
+ ),
+ config_subentry_id=subentry.subentry_id,
+ )
+
class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity):
"""Sensor for Data Grand Lyon stop departures."""
@@ -227,3 +278,17 @@ class DataGrandLyonVelovSensor(DataGrandLyonVelovEntity, SensorEntity):
return self.entity_description.value_fn(
self.coordinator.data[self._subentry_id]
)
+
+
+class DataGrandLyonParkAndRideSensor(DataGrandLyonParkAndRideEntity, SensorEntity):
+ """Sensor for Data Grand Lyon park-and-ride facility."""
+
+ entity_description: DataGrandLyonParkAndRideSensorEntityDescription
+
+ @property
+ @override
+ def native_value(self) -> StateType:
+ """Return the sensor value."""
+ return self.entity_description.value_fn(
+ self.coordinator.data[self._subentry_id]
+ )
diff --git a/homeassistant/components/data_grand_lyon/strings.json b/homeassistant/components/data_grand_lyon/strings.json
index 43be121570da..eb08a461a352 100644
--- a/homeassistant/components/data_grand_lyon/strings.json
+++ b/homeassistant/components/data_grand_lyon/strings.json
@@ -44,6 +44,28 @@
}
},
"config_subentries": {
+ "park_and_ride": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "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%]"
+ },
+ "entry_type": "Park & ride",
+ "initiate_flow": {
+ "user": "Add park & ride"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "park_id": "Park & ride"
+ },
+ "data_description": {
+ "park_id": "Search by name, or enter a park & ride ID directly."
+ }
+ }
+ }
+ },
"stop": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
@@ -107,6 +129,10 @@
}
},
"sensor": {
+ "accessible_spaces": {
+ "name": "Accessible parking spaces",
+ "unit_of_measurement": "spaces"
+ },
"available_bikes": {
"name": "Available bikes",
"unit_of_measurement": "bikes"
@@ -119,6 +145,10 @@
"name": "Available mechanical bikes",
"unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::available_bikes::unit_of_measurement%]"
},
+ "available_spaces": {
+ "name": "Available parking spaces",
+ "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::accessible_spaces::unit_of_measurement%]"
+ },
"available_stands": {
"name": "Available stands",
"unit_of_measurement": "stands"
@@ -173,6 +203,10 @@
"estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]",
"theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]"
}
+ },
+ "park_and_ride_capacity": {
+ "name": "Capacity",
+ "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::accessible_spaces::unit_of_measurement%]"
}
}
},
@@ -180,6 +214,9 @@
"auth_failed": {
"message": "Authentication failed for Data Grand Lyon."
},
+ "update_failed_park_and_ride": {
+ "message": "Error fetching park & ride availability from Data Grand Lyon."
+ },
"update_failed_tcl": {
"message": "Error fetching TCL departures from Data Grand Lyon."
},
diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py
index bff21a0c69cb..c06112f820a2 100644
--- a/homeassistant/components/deconz/services.py
+++ b/homeassistant/components/deconz/services.py
@@ -1,11 +1,11 @@
"""deCONZ services."""
-from typing import TYPE_CHECKING
-
+from pydeconz import errors
from pydeconz.utils import normalize_bridge_id
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -14,14 +14,10 @@ from homeassistant.helpers import (
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util.read_only_dict import ReadOnlyDict
-from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER
+from .const import CONF_BRIDGE_ID, DOMAIN
from .hub import DeconzHub
from .util import get_master_hub
-if TYPE_CHECKING:
- from . import DeconzConfigEntry
-
-
DECONZ_SERVICES = "deconz_services"
SERVICE_FIELD = "field"
@@ -68,27 +64,34 @@ def async_setup_services(hass: HomeAssistant) -> None:
service_data = service_call.data
if CONF_BRIDGE_ID in service_data:
- found_hub = False
bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID])
- entry: DeconzConfigEntry
- for entry in hass.config_entries.async_loaded_entries(DOMAIN):
- possible_hub = entry.runtime_data
- if possible_hub.bridgeid == bridge_id:
- hub = possible_hub
- found_hub = True
- break
+ hub: DeconzHub | None = next(
+ (
+ entry.runtime_data
+ for entry in hass.config_entries.async_loaded_entries(DOMAIN)
+ if entry.runtime_data.bridgeid == bridge_id
+ ),
+ None,
+ )
+
+ if hub is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="gateway_not_found",
+ translation_placeholders={
+ "bridge_id": bridge_id,
+ },
+ )
- if not found_hub:
- LOGGER.error("Could not find the gateway %s", bridge_id)
- return
else:
try:
hub = get_master_hub(hass)
- # pylint: disable-next=home-assistant-action-swallowed-exception
- except ValueError:
- LOGGER.error("No master gateway available")
- return
+ except ValueError as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_master_gateway",
+ ) from err
if service == SERVICE_CONFIGURE_DEVICE:
await async_configure_service(hub, service_data)
@@ -132,19 +135,38 @@ async def async_configure_service(hub: DeconzHub, data: ReadOnlyDict) -> None:
if entity_id:
try:
field = hub.deconz_ids[entity_id] + field
- except KeyError:
- LOGGER.error("Could not find the entity %s", entity_id)
- return
+ except KeyError as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="entity_not_found",
+ translation_placeholders={
+ "entity_id": entity_id,
+ },
+ ) from err
- await hub.api.request("put", field, json=data)
+ try:
+ await hub.api.request("put", field, json=data)
+ except (TimeoutError, errors.pydeconzException) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="configure_failed",
+ ) from err
async def async_refresh_devices_service(hub: DeconzHub) -> None:
"""Refresh available devices from deCONZ."""
hub.ignore_state_updates = True
- await hub.api.refresh_state()
- hub.load_ignored_devices()
- hub.ignore_state_updates = False
+
+ try:
+ await hub.api.refresh_state()
+ hub.load_ignored_devices()
+ except (TimeoutError, errors.pydeconzException) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="device_refresh_failed",
+ ) from err
+ finally:
+ hub.ignore_state_updates = False
async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
@@ -183,6 +205,7 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
if entry.device_id in devices_to_be_removed:
devices_to_be_removed.remove(entry.device_id)
continue
+
# Remove entities that are not available
entities_to_be_removed.append(entry.entity_id)
@@ -195,7 +218,9 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
if (
len(
er.async_entries_for_device(
- entity_registry, device_id, include_disabled_entities=True
+ entity_registry,
+ device_id,
+ include_disabled_entities=True,
)
)
== 0
diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json
index 2d21faa073b3..9789b4b464a0 100644
--- a/homeassistant/components/deconz/strings.json
+++ b/homeassistant/components/deconz/strings.json
@@ -96,6 +96,23 @@
"remote_turned_counter_clockwise": "Device turned counterclockwise"
}
},
+ "exceptions": {
+ "configure_failed": {
+ "message": "Failed to configure device"
+ },
+ "device_refresh_failed": {
+ "message": "Failed to refresh devices"
+ },
+ "entity_not_found": {
+ "message": "Could not find entity {entity_id}"
+ },
+ "gateway_not_found": {
+ "message": "Could not find gateway {bridge_id}"
+ },
+ "no_master_gateway": {
+ "message": "No master gateway available"
+ }
+ },
"options": {
"step": {
"deconz_devices": {
diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py
index df35a749ee20..f0731f8f0a78 100644
--- a/homeassistant/components/demo/media_player.py
+++ b/homeassistant/components/demo/media_player.py
@@ -407,9 +407,9 @@ class DemoTVShowPlayer(AbstractDemoPlayer):
class DemoBrowsePlayer(AbstractDemoPlayer):
- """A Demo media player that supports browse."""
+ """A Demo media player that supports browse and search."""
- _attr_supported_features = BROWSE_PLAYER_SUPPORT
+ _attr_supported_features = BROWSE_PLAYER_SUPPORT | SEARCH_PLAYER_SUPPORT
@override
async def async_browse_media(
@@ -421,6 +421,13 @@ class DemoBrowsePlayer(AbstractDemoPlayer):
return await media_source.async_browse_media(self.hass, media_content_id)
+ @override
+ async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia:
+ """Implement the websocket media search helper by delegating to media source."""
+ return await media_source.async_search_media(
+ self.hass, query.media_content_id, query
+ )
+
class DemoGroupPlayer(AbstractDemoPlayer):
"""A Demo media player that supports grouping."""
diff --git a/homeassistant/components/denon_rs232/manifest.json b/homeassistant/components/denon_rs232/manifest.json
index e50677a5f4f6..d8a0c98e96d4 100644
--- a/homeassistant/components/denon_rs232/manifest.json
+++ b/homeassistant/components/denon_rs232/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["denon_rs232"],
"quality_scale": "bronze",
- "requirements": ["denon-rs232==4.1.0"]
+ "requirements": ["denon-rs232==4.2.1"]
}
diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py
index ce593e5f8f8c..9814bb80b6d9 100644
--- a/homeassistant/components/derivative/__init__.py
+++ b/homeassistant/components/derivative/__init__.py
@@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py
index 53e4e5dfa727..437eb92e336c 100644
--- a/homeassistant/components/derivative/config_flow.py
+++ b/homeassistant/components/derivative/config_flow.py
@@ -8,12 +8,7 @@ import voluptuous as vol
from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
-from homeassistant.const import (
- ATTR_UNIT_OF_MEASUREMENT,
- CONF_NAME,
- CONF_SOURCE,
- UnitOfTime,
-)
+from homeassistant.const import CONF_NAME, CONF_SOURCE, EntityStateAttribute, UnitOfTime
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
@@ -59,13 +54,16 @@ def entity_selector_compatible(
"""Return an entity selector which compatible entities."""
current = handler.hass.states.get(handler.options[CONF_SOURCE])
unit_of_measurement = (
- current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None
+ current.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ if current
+ else None
)
entities = [
ent.entity_id
for ent in handler.hass.states.async_all(ALLOWED_DOMAINS)
- if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement
+ if ent.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ == unit_of_measurement
and ent.domain in ALLOWED_DOMAINS
]
diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py
index ab7431fbef2c..0e25e72ea1b4 100644
--- a/homeassistant/components/derivative/sensor.py
+++ b/homeassistant/components/derivative/sensor.py
@@ -8,23 +8,22 @@ from typing import override
import voluptuous as vol
from homeassistant.components.sensor import (
- ATTR_STATE_CLASS,
DEVICE_CLASS_UNITS,
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
RestoreSensor,
SensorDeviceClass,
SensorEntity,
+ SensorEntityCapabilityAttribute,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_DEVICE_CLASS,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_SOURCE,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
Platform,
UnitOfTime,
)
@@ -243,7 +242,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
if not source_state:
return
- source_class_raw = source_state.attributes.get(ATTR_DEVICE_CLASS)
+ source_class_raw = source_state.attributes.get(
+ EntityStateAttribute.DEVICE_CLASS
+ )
source_class: SensorDeviceClass | None = None
if isinstance(source_class_raw, str):
try:
@@ -252,7 +253,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
source_class = None
if self._string_unit_prefix is not None and self._string_unit_time is not None:
original_unit = self._attr_native_unit_of_measurement
- source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ source_unit = source_state.attributes.get(
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
+ )
if (
(
source_class
@@ -366,7 +369,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
last_state = await self.async_get_last_state()
if last_state:
- self._attr_device_class = last_state.attributes.get(ATTR_DEVICE_CLASS)
+ self._attr_device_class = last_state.attributes.get(
+ EntityStateAttribute.DEVICE_CLASS
+ )
@override
async def async_added_to_hass(self) -> None:
@@ -540,7 +545,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
# A negative derivative for a total increasing sensor likely indicates the
# sensor has been reset. To prevent inaccurate data, discard this sample.
if (
- new_state.attributes.get(ATTR_STATE_CLASS)
+ new_state.attributes.get(SensorEntityCapabilityAttribute.STATE_CLASS)
== SensorStateClass.TOTAL_INCREASING
and new_derivative < 0
):
diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py
index b2fa4bbb06f0..f7c5bfc32b5c 100644
--- a/homeassistant/components/device_automation/helpers.py
+++ b/homeassistant/components/device_automation/helpers.py
@@ -43,6 +43,32 @@ ENTITY_PLATFORMS = {
}
+def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str:
+ """Resolve a device automation device id, following a composite device id.
+
+ A device automation created when a device could be connected to more than one
+ config entry stores the id of the (now removed) composite device. When the
+ automation's domain owns one of the split devices' config entries, resolve to that
+ device - an integration may look the device up in its own registry, which only
+ knows the current device id, not the removed composite id.
+ """
+ device_registry = dr.async_get(hass)
+ if device_id in device_registry.devices:
+ return device_id
+ if not (
+ split_devices := device_registry.async_get_devices_for_composite_device_id(
+ device_id
+ )
+ ):
+ return device_id
+ # Resolve to the device owned by a config entry of the automation's domain
+ for split_device in split_devices:
+ entry = hass.config_entries.async_get_entry(split_device.config_entry_id)
+ if entry is not None and entry.domain == domain:
+ return split_device.id
+ return device_id
+
+
async def async_validate_device_automation_config(
hass: HomeAssistant,
config: ConfigType,
@@ -51,6 +77,17 @@ async def async_validate_device_automation_config(
) -> ConfigType:
"""Validate config."""
validated_config: ConfigType = automation_schema(config)
+
+ # A device automation may reference a pre-migration composite device id; resolve it
+ # to the split device for its domain so the device and its entities exist and the
+ # integration platform (validation and attach/call) receives a live device id
+ resolved_device_id = _resolve_device_id(
+ hass, validated_config[CONF_DEVICE_ID], validated_config[CONF_DOMAIN]
+ )
+ if resolved_device_id != validated_config[CONF_DEVICE_ID]:
+ config = {**config, CONF_DEVICE_ID: resolved_device_id}
+ validated_config = {**validated_config, CONF_DEVICE_ID: resolved_device_id}
+
platform = await async_get_device_automation_platform(
hass, validated_config[CONF_DOMAIN], automation_type
)
diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py
index 8f1489256a6a..6e50faa06eb2 100644
--- a/homeassistant/components/device_tracker/__init__.py
+++ b/homeassistant/components/device_tracker/__init__.py
@@ -36,7 +36,11 @@ from .const import ( # noqa: F401
LOGGER,
PLATFORM_TYPE_LEGACY,
SCAN_INTERVAL,
+ DeviceTrackerEntityCapabilityAttribute,
+ DeviceTrackerEntityStateAttribute,
+ ScannerEntityStateAttribute,
SourceType,
+ TrackerEntityStateAttribute,
TrackingType,
)
from .entity import ( # noqa: F401
diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py
index 920e0f5994e6..7fcbb74ca3d0 100644
--- a/homeassistant/components/device_tracker/const.py
+++ b/homeassistant/components/device_tracker/const.py
@@ -5,6 +5,7 @@ from enum import StrEnum
import logging
from typing import Final
+from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers
from homeassistant.util.signal_type import SignalType
LOGGER: Final = logging.getLogger(__package__)
@@ -50,11 +51,18 @@ class DeviceTrackerEntityStateAttribute(StrEnum):
IN_ZONES = "in_zones"
-class TrackerEntityStateAttribute(StrEnum):
+class TrackerEntityStateAttribute(
+ StrEnum,
+ metaclass=EnumWithDeprecatedMembers,
+ deprecated={
+ "LATITUDE": ("EntityStateAttribute.LATITUDE", "2027.2.0"),
+ "LONGITUDE": ("EntityStateAttribute.LONGITUDE", "2027.2.0"),
+ },
+):
"""State attributes set by TrackerEntity."""
- LATITUDE = "latitude"
- LONGITUDE = "longitude"
+ LATITUDE = "latitude" # Deprecated, replaced with EntityStateAttribute.LATITUDE
+ LONGITUDE = "longitude" # Deprecated, replaced with EntityStateAttribute.LONGITUDE
GPS_ACCURACY = "gps_accuracy"
diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py
index 7f1b77b37198..ffa8a1197fc6 100644
--- a/homeassistant/components/device_tracker/entity.py
+++ b/homeassistant/components/device_tracker/entity.py
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, final, override
from propcache.api import cached_property
from homeassistant.components import zone
-from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
+from homeassistant.components.zone import ZoneEntityStateAttribute
from homeassistant.const import ( # noqa: F401
ATTR_BATTERY_LEVEL,
ATTR_GPS_ACCURACY,
@@ -16,6 +16,7 @@ from homeassistant.const import ( # noqa: F401
STATE_HOME,
STATE_NOT_HOME,
EntityCategory,
+ EntityStateAttribute,
)
from homeassistant.core import (
CALLBACK_TYPE,
@@ -368,10 +369,14 @@ class TrackerEntity(
for entity_id in zones
if (zone_state := self.hass.states.get(entity_id)) is not None
),
- key=lambda z: z.attributes[ATTR_RADIUS],
+ key=lambda z: z.attributes[ZoneEntityStateAttribute.RADIUS],
)
self.__active_zone = next(
- (z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
+ (
+ z
+ for z in zone_states
+ if not z.attributes.get(ZoneEntityStateAttribute.PASSIVE)
+ ),
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
@@ -418,8 +423,8 @@ class TrackerEntity(
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
- attr[TrackerEntityStateAttribute.LATITUDE] = self.latitude
- attr[TrackerEntityStateAttribute.LONGITUDE] = self.longitude
+ attr[EntityStateAttribute.LATITUDE] = self.latitude
+ attr[EntityStateAttribute.LONGITUDE] = self.longitude
attr[TrackerEntityStateAttribute.GPS_ACCURACY] = self.location_accuracy
return attr
@@ -702,17 +707,29 @@ class ScannerEntity(
await super().async_internal_added_to_hass()
return
- # Attach entry to device
- if self.registry_entry.device_id != device_entry.id:
- self.registry_entry = er.async_get(self.hass).async_update_entity(
- self.entity_id, device_id=device_entry.id
+ dev_reg = dr.async_get(self.hass)
+ # find_device_entry may return a synthesized pre-migration composite whose id is
+ # not a real device and can't be assigned to an entity; resolve it to the split
+ # owned by this config entry so we attach to a concrete device.
+ if device_entry.id not in dev_reg.devices:
+ device_entry = next(
+ (
+ split
+ for split in dev_reg.async_get_devices_for_composite_device_id(
+ device_entry.id
+ )
+ if split.config_entry_id == self.platform.config_entry.entry_id
+ ),
+ None,
)
- # Attach device to config entry
- if self.platform.config_entry.entry_id not in device_entry.config_entries:
- dr.async_get(self.hass).async_update_device(
- device_entry.id,
- add_config_entry_id=self.platform.config_entry.entry_id,
+ # Attach entry to device
+ if (
+ device_entry is not None
+ and self.registry_entry.device_id != device_entry.id
+ ):
+ self.registry_entry = er.async_get(self.hass).async_update_entity(
+ self.entity_id, device_id=device_entry.id
)
# Do this last or else the entity registry update listener has been installed
diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py
index 5fedb6686515..a3fb9fdb4457 100644
--- a/homeassistant/components/device_tracker/legacy.py
+++ b/homeassistant/components/device_tracker/legacy.py
@@ -24,8 +24,6 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_GPS_ACCURACY,
ATTR_ICON,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
ATTR_NAME,
CONF_ICON,
CONF_MAC,
@@ -34,6 +32,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
STATE_HOME,
STATE_NOT_HOME,
+ EntityStateAttribute,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
@@ -77,7 +76,9 @@ from .const import (
LOGGER,
PLATFORM_TYPE_LEGACY,
SCAN_INTERVAL,
+ DeviceTrackerEntityStateAttribute,
SourceType,
+ TrackerEntityStateAttribute,
)
_LOGGER = logging.getLogger(__name__)
@@ -509,8 +510,8 @@ def async_setup_scanner_platform(
zone_home = hass.states.get(ENTITY_ID_HOME)
if zone_home is not None:
kwargs["gps"] = [
- zone_home.attributes[ATTR_LATITUDE],
- zone_home.attributes[ATTR_LONGITUDE],
+ zone_home.attributes[EntityStateAttribute.LATITUDE],
+ zone_home.attributes[EntityStateAttribute.LONGITUDE],
]
kwargs["gps_accuracy"] = 0
@@ -840,12 +841,14 @@ class Device(RestoreEntity):
@override
def state_attributes(self) -> dict[str, StateType]:
"""Return the device state attributes."""
- attributes: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
+ attributes: dict[str, StateType] = {
+ DeviceTrackerEntityStateAttribute.SOURCE_TYPE: self.source_type
+ }
if self.gps is not None:
- attributes[ATTR_LATITUDE] = self.gps[0]
- attributes[ATTR_LONGITUDE] = self.gps[1]
- attributes[ATTR_GPS_ACCURACY] = self.gps_accuracy
+ attributes[EntityStateAttribute.LATITUDE] = self.gps[0]
+ attributes[EntityStateAttribute.LONGITUDE] = self.gps[1]
+ attributes[TrackerEntityStateAttribute.GPS_ACCURACY] = self.gps_accuracy
if self.battery is not None:
attributes[ATTR_BATTERY] = self.battery
@@ -952,17 +955,17 @@ class Device(RestoreEntity):
self.last_seen = dt_util.utcnow()
for attribute, var in (
- (ATTR_SOURCE_TYPE, "source_type"),
- (ATTR_GPS_ACCURACY, "gps_accuracy"),
+ (DeviceTrackerEntityStateAttribute.SOURCE_TYPE, "source_type"),
+ (TrackerEntityStateAttribute.GPS_ACCURACY, "gps_accuracy"),
(ATTR_BATTERY, "battery"),
):
if attribute in state.attributes:
setattr(self, var, state.attributes[attribute])
- if ATTR_LONGITUDE in state.attributes:
+ if EntityStateAttribute.LONGITUDE in state.attributes:
self.gps = (
- state.attributes[ATTR_LATITUDE],
- state.attributes[ATTR_LONGITUDE],
+ state.attributes[EntityStateAttribute.LATITUDE],
+ state.attributes[EntityStateAttribute.LONGITUDE],
)
diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py
index f497b273bcc9..48f6077d5371 100644
--- a/homeassistant/components/dhcp/__init__.py
+++ b/homeassistant/components/dhcp/__init__.py
@@ -61,8 +61,13 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp
from . import websocket_api
from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS
+from .helpers import async_discovered_service_info
from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers
+__all__ = [
+ "async_discovered_service_info",
+]
+
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
REGISTERED_DEVICES: Final = "registered_devices"
diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py
index 7acf26f76fde..9c81fce1405c 100644
--- a/homeassistant/components/dhcp/helpers.py
+++ b/homeassistant/components/dhcp/helpers.py
@@ -4,7 +4,9 @@ from collections.abc import Callable
from functools import partial
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from .const import HOSTNAME, IP_ADDRESS
from .models import DATA_DHCP, DHCPAddressData
@@ -33,3 +35,16 @@ def async_get_address_data_internal(
This is not intended for use by integrations.
"""
return hass.data[DATA_DHCP].address_data
+
+
+@callback
+def async_discovered_service_info(hass: HomeAssistant) -> list[DhcpServiceInfo]:
+ """Return the discovered DHCP devices."""
+ return [
+ DhcpServiceInfo(
+ ip=data[IP_ADDRESS],
+ hostname=data[HOSTNAME].lower(),
+ macaddress=mac_address,
+ )
+ for mac_address, data in async_get_address_data_internal(hass).items()
+ ]
diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py
index 9d4b53093055..bca2cc73fd9e 100644
--- a/homeassistant/components/diagnostics/__init__.py
+++ b/homeassistant/components/diagnostics/__init__.py
@@ -36,9 +36,14 @@ from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import format_unserializable_data
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
-from .util import async_redact_data, entity_entry_as_dict
+from .util import async_redact_data, device_entry_as_dict, entity_entry_as_dict
-__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"]
+__all__ = [
+ "REDACTED",
+ "async_redact_data",
+ "device_entry_as_dict",
+ "entity_entry_as_dict",
+]
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py
index 5dd6085e2df0..9326961c5d8c 100644
--- a/homeassistant/components/diagnostics/util.py
+++ b/homeassistant/components/diagnostics/util.py
@@ -6,6 +6,7 @@ from typing import Any, cast, overload
import attr
from homeassistant.core import callback
+from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_registry import RegistryEntry
from .const import REDACTED
@@ -45,6 +46,33 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
return cast(_T, redacted)
+# DeviceEntry attributes that are internal bookkeeping and must not be exposed in
+# diagnostics. Underscore attributes (_cache, _suggested_area, and the transient
+# _pending_move / _composite_subentries) are excluded separately by _device_entry_filter.
+# The composite-device migration attributes below can be removed in HA Core 2027.8.
+_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = (
+ "composite_device_id",
+ "composite_primary_config_entry",
+ "has_composite_identifiers",
+ "split_at",
+)
+
+
+def _device_entry_filter(a: attr.Attribute, _: Any) -> bool:
+ return (
+ not a.name.startswith("_") and a.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES
+ )
+
+
+@callback
+def device_entry_as_dict(entry: DeviceEntry) -> dict[str, Any]:
+ """Convert a device registry entry to a dict for diagnostics.
+
+ This excludes internal fields that should not be exposed in diagnostics.
+ """
+ return attr.asdict(entry, filter=_device_entry_filter)
+
+
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
return a.name not in (
"_cache",
diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py
index 8c1e80527f8c..dd577fb3c72c 100644
--- a/homeassistant/components/discord/__init__.py
+++ b/homeassistant/components/discord/__init__.py
@@ -37,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
finally:
await discord_bot.close()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data
-
hass.async_create_task(
discovery.async_load_platform(
hass, Platform.NOTIFY, DOMAIN, dict(entry.data), hass.data[DATA_HASS_CONFIG]
diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json
index dc218ac32a48..d1290658b04c 100644
--- a/homeassistant/components/dlna_dmr/manifest.json
+++ b/homeassistant/components/dlna_dmr/manifest.json
@@ -9,7 +9,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
- "requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"],
+ "requirements": ["async-upnp-client==0.47.0", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json
index f92582336f47..09e75ff3b0ca 100644
--- a/homeassistant/components/dlna_dms/manifest.json
+++ b/homeassistant/components/dlna_dms/manifest.json
@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["async-upnp-client==0.46.2"],
+ "requirements": ["async-upnp-client==0.47.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json
index bee7cb77b297..1694a024a833 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==12.2.0"]
+ "requirements": ["pydoods==1.0.2", "Pillow==12.3.0"]
}
diff --git a/homeassistant/components/doorbell/trigger.py b/homeassistant/components/doorbell/trigger.py
index 6d68ddb8889b..6e54e1ad8ba1 100644
--- a/homeassistant/components/doorbell/trigger.py
+++ b/homeassistant/components/doorbell/trigger.py
@@ -3,10 +3,10 @@
from typing import override
from homeassistant.components.event import (
- ATTR_EVENT_TYPE,
DOMAIN as EVENT_DOMAIN,
DoorbellEventType,
EventDeviceClass,
+ EventEntityStateAttribute,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
@@ -29,7 +29,10 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase):
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the event type is ring."""
- return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
+ return (
+ state.attributes.get(EventEntityStateAttribute.EVENT_TYPE)
+ == DoorbellEventType.RING
+ )
TRIGGERS: dict[str, type[Trigger]] = {
diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py
index 4bfe4791d247..439307d8dbac 100644
--- a/homeassistant/components/dsmr/config_flow.py
+++ b/homeassistant/components/dsmr/config_flow.py
@@ -5,11 +5,8 @@ from functools import partial
from typing import Any, override
from dsmr_parser import obis_references as obis_ref
-from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader
-from dsmr_parser.clients.rfxtrx_protocol import (
- create_rfxtrx_dsmr_reader,
- create_rfxtrx_tcp_dsmr_reader,
-)
+from dsmr_parser.clients.protocol import create_dsmr_reader
+from dsmr_parser.clients.rfxtrx_protocol import create_rfxtrx_dsmr_reader
from dsmr_parser.objects import DSMRObject
import voluptuous as vol
@@ -19,7 +16,7 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE
+from homeassistant.const import CONF_PORT, CONF_PROTOCOL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SerialPortSelector
@@ -41,11 +38,8 @@ from .const import (
class DSMRConnection:
"""Test the connection to DSMR and receive telegram to read serial ids."""
- def __init__(
- self, host: str | None, port: int, dsmr_version: str, protocol: str
- ) -> None:
+ def __init__(self, port: str, dsmr_version: str, protocol: str) -> None:
"""Initialize."""
- self._host = host
self._port = port
self._dsmr_version = dsmr_version
self._protocol = protocol
@@ -86,34 +80,20 @@ class DSMRConnection:
self._telegram = telegram
transport.close()
- if self._host is None:
- if self._protocol == DSMR_PROTOCOL:
- create_reader = create_dsmr_reader
- else:
- create_reader = create_rfxtrx_dsmr_reader
- reader_factory = partial(
- create_reader,
- self._port,
- self._dsmr_version,
- update_telegram,
- loop=hass.loop,
- )
+ if self._protocol == DSMR_PROTOCOL:
+ create_reader = create_dsmr_reader
else:
- if self._protocol == DSMR_PROTOCOL:
- create_reader = create_tcp_dsmr_reader
- else:
- create_reader = create_rfxtrx_tcp_dsmr_reader
- reader_factory = partial(
- create_reader,
- self._host,
- self._port,
- self._dsmr_version,
- update_telegram,
- loop=hass.loop,
- )
+ create_reader = create_rfxtrx_dsmr_reader
+ reader_factory = partial(
+ create_reader,
+ self._port,
+ self._dsmr_version,
+ update_telegram,
+ loop=hass.loop,
+ )
try:
- transport, protocol = await asyncio.create_task(reader_factory())
+ transport, protocol = await reader_factory()
except OSError:
LOGGER.exception("Error connecting to DSMR")
return False
@@ -136,7 +116,6 @@ async def _validate_dsmr_connection(
) -> dict[str, str | None]:
"""Validate the user input allows us to connect."""
conn = DSMRConnection(
- data.get(CONF_HOST),
data[CONF_PORT],
data[CONF_DSMR_VERSION],
protocol,
@@ -176,48 +155,12 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Step when user initializes a integration."""
- if user_input is not None:
- user_selection = user_input[CONF_TYPE]
- if user_selection == "Serial":
- return await self.async_step_setup_serial()
+ """Step when user initializes an integration.
- return await self.async_step_setup_network()
-
- list_of_types = ["Serial", "Network"]
-
- schema = vol.Schema({vol.Required(CONF_TYPE): vol.In(list_of_types)})
- return self.async_show_form(step_id="user", data_schema=schema)
-
- async def async_step_setup_network(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Step when setting up network configuration."""
- errors: dict[str, str] = {}
- if user_input is not None:
- data = await self.async_validate_dsmr(user_input, errors)
- if not errors:
- return self.async_create_entry(
- title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data
- )
-
- schema = vol.Schema(
- {
- vol.Required(CONF_HOST): str,
- vol.Required(CONF_PORT): int,
- vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS),
- }
- )
- return self.async_show_form(
- step_id="setup_network",
- data_schema=schema,
- errors=errors,
- )
-
- async def async_step_setup_serial(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Step when setting up serial configuration."""
+ A single serial port selector handles both local serial devices and
+ network connections; a network meter can be reached by entering a URL
+ such as ``socket://host:port``.
+ """
errors: dict[str, str] = {}
if user_input is not None:
data = await self.async_validate_dsmr(user_input, errors)
@@ -231,7 +174,7 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
}
)
return self.async_show_form(
- step_id="setup_serial",
+ step_id="user",
data_schema=schema,
errors=errors,
)
diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json
index c32d947e610c..dba3a4d6e314 100644
--- a/homeassistant/components/dsmr/manifest.json
+++ b/homeassistant/components/dsmr/manifest.json
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["dsmr_parser"],
- "requirements": ["dsmr-parser==1.9.0"]
+ "requirements": ["dsmr-parser==1.11.1"]
}
diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py
index 01407013cce7..e4340404a1b4 100644
--- a/homeassistant/components/dsmr/sensor.py
+++ b/homeassistant/components/dsmr/sensor.py
@@ -9,8 +9,9 @@ from datetime import timedelta
from enum import IntEnum
from functools import partial
from typing import override
+from urllib.parse import urlparse
-from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader
+from dsmr_parser.clients.protocol import create_dsmr_reader
from dsmr_parser.clients.rfxtrx_protocol import (
create_rfxtrx_dsmr_reader,
create_rfxtrx_tcp_dsmr_reader,
@@ -61,6 +62,7 @@ from .const import (
DOMAIN,
DSMR_PROTOCOL,
LOGGER,
+ RFXTRX_DSMR_PROTOCOL,
)
EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}"
@@ -770,34 +772,54 @@ async def async_setup_entry(
hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), telegram
)
- # Creates an asyncio.Protocol factory for reading DSMR telegrams from
- # serial and calls update_entities_telegram to update entities on arrival
- protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL)
+ # Legacy network entries stored host and port separately; combine them into
+ # the single socket://host:port form newer entries already use, in memory
+ # only, so the stored entry stays untouched and rolling back to an older
+ # Home Assistant version keeps working.
+ port = entry.data[CONF_PORT]
if CONF_HOST in entry.data:
- if protocol == DSMR_PROTOCOL:
- create_reader = create_tcp_dsmr_reader
+ port = f"socket://{entry.data[CONF_HOST]}:{port}"
+
+ # Creates an asyncio.Protocol factory for reading DSMR telegrams and calls
+ # update_entities_telegram to update entities on arrival. A port starting
+ # with "/" is a local serial device, which doesn't need a liveness check;
+ # anything else is a network connection that can drop silently, so it gets a
+ # keep-alive watchdog that closes the connection (triggering a reconnect)
+ # when no telegram arrives in time.
+ protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL)
+ if protocol == RFXTRX_DSMR_PROTOCOL:
+ if port.startswith("/"):
+ reader_factory = partial(
+ create_rfxtrx_dsmr_reader,
+ port,
+ dsmr_version,
+ update_entities_telegram,
+ loop=hass.loop,
+ )
else:
- create_reader = create_rfxtrx_tcp_dsmr_reader
- reader_factory = partial(
- create_reader,
- entry.data[CONF_HOST],
- entry.data[CONF_PORT],
- dsmr_version,
- update_entities_telegram,
- loop=hass.loop,
- keep_alive_interval=60,
- )
+ # The RFXtrx serial reader has no keep-alive support, so the network
+ # host and port are fed to the dedicated TCP reader instead.
+ address = urlparse(port)
+ reader_factory = partial(
+ create_rfxtrx_tcp_dsmr_reader,
+ address.hostname,
+ address.port,
+ dsmr_version,
+ update_entities_telegram,
+ loop=hass.loop,
+ keep_alive_interval=60,
+ )
else:
- if protocol == DSMR_PROTOCOL:
- create_reader = create_dsmr_reader
- else:
- create_reader = create_rfxtrx_dsmr_reader
+ # create_dsmr_reader opens both local devices and any URL (socket://,
+ # esphome://, ...); the only difference is the keep-alive watchdog.
+ keep_alive = {} if port.startswith("/") else {"keep_alive_interval": 60}
reader_factory = partial(
- create_reader,
- entry.data[CONF_PORT],
+ create_dsmr_reader,
+ port,
dsmr_version,
update_entities_telegram,
loop=hass.loop,
+ **keep_alive,
)
async def connect_and_reconnect() -> None:
@@ -814,7 +836,7 @@ async def async_setup_entry(
update_entities_telegram({})
try:
- transport, protocol = await hass.loop.create_task(reader_factory())
+ transport, protocol = await reader_factory()
if transport:
# Register listener to close transport on HA shutdown
diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json
index 519c23cc5694..f8fe86fa4368 100644
--- a/homeassistant/components/dsmr/strings.json
+++ b/homeassistant/components/dsmr/strings.json
@@ -11,26 +11,15 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
- "setup_network": {
- "data": {
- "dsmr_version": "Select DSMR version",
- "host": "[%key:common::config_flow::data::host%]",
- "port": "[%key:common::config_flow::data::port%]"
- },
- "title": "Select connection address"
- },
- "setup_serial": {
- "data": {
- "dsmr_version": "[%key:component::dsmr::config::step::setup_network::data::dsmr_version%]",
- "port": "Select device"
- },
- "title": "[%key:common::config_flow::data::device%]"
- },
"user": {
"data": {
- "type": "Connection type"
+ "dsmr_version": "Select DSMR version",
+ "port": "Select device"
},
- "title": "Select connection type"
+ "data_description": {
+ "port": "Select a serial device, or enter a network address such as socket://host:port"
+ },
+ "title": "[%key:common::config_flow::data::device%]"
}
}
},
diff --git a/homeassistant/components/duco/__init__.py b/homeassistant/components/duco/__init__.py
index bc75a4597117..09c79893ad5f 100644
--- a/homeassistant/components/duco/__init__.py
+++ b/homeassistant/components/duco/__init__.py
@@ -17,8 +17,8 @@ _REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
- # Remove entity registry entries for the temperature and box_temperature
- # sensors that were removed when migrating to python-duco-connectivity.
+ # Clean up stale temperature registry entries so removed entities from the
+ # python-duco-connectivity migration do not linger after upgrade.
entity_registry = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(
entity_registry, entry.entry_id
@@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool
)
coordinator = DucoCoordinator(hass, entry, client)
+
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py
index 4e8ef4993a0f..10cbf619c96e 100644
--- a/homeassistant/components/duco/coordinator.py
+++ b/homeassistant/components/duco/coordinator.py
@@ -1,7 +1,7 @@
"""Data update coordinator for the Duco integration."""
from contextlib import suppress
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
import logging
from typing import cast, override
@@ -11,7 +11,7 @@ from duco_connectivity.exceptions import (
DucoError,
DucoResponseError,
)
-from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList
+from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList, NodeName
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -42,6 +42,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
config_entry: DucoConfigEntry
board_info: BoardInfo
_supports_time_filter_remain: bool
+ _configured_node_names: dict[int, str]
def __init__(
self,
@@ -58,8 +59,25 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
update_interval=SCAN_INTERVAL,
)
self.client = client
+ self._configured_node_names = {}
self._supports_time_filter_remain = True
+ async def _async_load_node_names(self) -> None:
+ """Load configured Duco node names during setup."""
+ try:
+ configured_node_names = await self.client.async_get_node_configs(
+ parameter="Name"
+ )
+ except DucoError as err:
+ _LOGGER.debug("Could not fetch Duco node names", exc_info=err)
+ return
+
+ self._configured_node_names = {
+ node.node_id: node.name.value
+ for node in configured_node_names.nodes
+ if node.name is not None
+ }
+
@override
async def _async_setup(self) -> None:
"""Fetch board info once during initial setup."""
@@ -86,6 +104,8 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
translation_key="api_error",
) from err
+ await self._async_load_node_names()
+
@override
async def _async_update_data(self) -> DucoData:
"""Fetch node data from the Duco box."""
@@ -102,6 +122,22 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
translation_key="api_error",
) from err
+ if self._configured_node_names:
+ nodes = [
+ replace(
+ node,
+ general=replace(
+ node.general,
+ name=NodeName(
+ self._configured_node_names.get(
+ node.node_id, node.general.name
+ )
+ ),
+ ),
+ )
+ for node in nodes
+ ]
+
try:
node_actions = await self.client.async_get_node_actions()
except DucoError as err:
diff --git a/homeassistant/components/duco/entity.py b/homeassistant/components/duco/entity.py
index 851c99d2d0d4..b14332791f88 100644
--- a/homeassistant/components/duco/entity.py
+++ b/homeassistant/components/duco/entity.py
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""
-from typing import override
+from typing import TYPE_CHECKING, override
from duco_connectivity.models import Node, NodeType
@@ -22,7 +22,8 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
super().__init__(coordinator)
self._node_id = node.node_id
mac = coordinator.config_entry.unique_id
- assert mac is not None
+ if TYPE_CHECKING:
+ assert mac is not None
device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json
index 85cc883e0536..ee7222fe9c28 100644
--- a/homeassistant/components/duco/manifest.json
+++ b/homeassistant/components/duco/manifest.json
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco_connectivity"],
"quality_scale": "platinum",
- "requirements": ["python-duco-connectivity==0.7.1"],
+ "requirements": ["python-duco-connectivity==0.10.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py
index 7945f39aeb29..67818456dbe3 100644
--- a/homeassistant/components/dwd_weather_warnings/__init__.py
+++ b/homeassistant/components/dwd_weather_warnings/__init__.py
@@ -13,7 +13,7 @@ async def async_setup_entry(
"""Set up a config entry."""
device_registry = dr.async_get(hass)
if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}):
- device_registry.async_clear_config_entry(entry.entry_id)
+ device_registry.async_clear_config_entry(entry.entry_id, entry.domain)
coordinator = DwdWeatherWarningsCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/dwd_weather_warnings/util.py b/homeassistant/components/dwd_weather_warnings/util.py
index 01398ef595c6..1f35d7ecfadd 100644
--- a/homeassistant/components/dwd_weather_warnings/util.py
+++ b/homeassistant/components/dwd_weather_warnings/util.py
@@ -1,6 +1,6 @@
"""Util functions for the dwd_weather_warnings integration."""
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -20,19 +20,20 @@ def get_position_data(
if entity is None:
raise EntityNotFoundError(f"Failed to find entity {registry_entry.entity_id}")
- latitude = entity.attributes.get(ATTR_LATITUDE)
+ latitude = entity.attributes.get(EntityStateAttribute.LATITUDE)
if not latitude:
raise AttributeError(
- f"Failed to find attribute '{ATTR_LATITUDE}' in {registry_entry.entity_id}",
- ATTR_LATITUDE,
+ f"Failed to find attribute '{EntityStateAttribute.LATITUDE}'"
+ f" in {registry_entry.entity_id}",
+ EntityStateAttribute.LATITUDE,
)
- longitude = entity.attributes.get(ATTR_LONGITUDE)
+ longitude = entity.attributes.get(EntityStateAttribute.LONGITUDE)
if not longitude:
raise AttributeError(
- f"Failed to find attribute '{ATTR_LONGITUDE}'"
+ f"Failed to find attribute '{EntityStateAttribute.LONGITUDE}'"
f" in {registry_entry.entity_id}",
- ATTR_LONGITUDE,
+ EntityStateAttribute.LONGITUDE,
)
return (latitude, longitude)
diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py
index 672201b2c37b..d6040a427161 100644
--- a/homeassistant/components/dynalite/cover.py
+++ b/homeassistant/components/dynalite/cover.py
@@ -3,9 +3,9 @@
from typing import Any, override
from homeassistant.components.cover import (
- ATTR_CURRENT_POSITION,
CoverDeviceClass,
CoverEntity,
+ CoverEntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -89,7 +89,7 @@ class DynaliteCover(DynaliteBase, CoverEntity):
@override
def initialize_state(self, state):
"""Initialize the state from cache."""
- target_level = state.attributes.get(ATTR_CURRENT_POSITION)
+ target_level = state.attributes.get(CoverEntityStateAttribute.CURRENT_POSITION)
if target_level is not None:
self._device.init_level(target_level)
diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py
index 33a8c23c547d..c818f51b3602 100644
--- a/homeassistant/components/dynalite/light.py
+++ b/homeassistant/components/dynalite/light.py
@@ -2,7 +2,11 @@
from typing import Any, override
-from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
+from homeassistant.components.light import (
+ ColorMode,
+ LightEntity,
+ LightEntityStateAttribute,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -52,6 +56,6 @@ class DynaliteLight(DynaliteBase, LightEntity):
@override
def initialize_state(self, state):
"""Initialize the state from cache."""
- target_level = state.attributes.get(ATTR_BRIGHTNESS)
+ target_level = state.attributes.get(LightEntityStateAttribute.BRIGHTNESS)
if target_level is not None:
self._device.init_level(target_level)
diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py
index e7462b40143a..1b0a4c031143 100644
--- a/homeassistant/components/ecobee/__init__.py
+++ b/homeassistant/components/ecobee/__init__.py
@@ -22,15 +22,26 @@ from homeassistant.exceptions import (
ConfigEntryError,
ConfigEntryNotReady,
)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from .const import _LOGGER, CONF_REFRESH_TOKEN, DOMAIN, PLATFORMS
+from .services import async_setup_services
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
type EcobeeConfigEntry = ConfigEntry[EcobeeData]
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the ecobee integration."""
+ async_setup_services(hass)
+ return True
+
+
async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool:
"""Set up ecobee via a config entry."""
api_key = entry.data.get(CONF_API_KEY)
diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py
index 81902a2cd2cd..dcc1e8f38416 100644
--- a/homeassistant/components/ecobee/climate.py
+++ b/homeassistant/components/ecobee/climate.py
@@ -20,7 +20,6 @@ from homeassistant.components.climate import (
HVACMode,
)
from homeassistant.const import (
- ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_TENTHS,
@@ -28,7 +27,7 @@ from homeassistant.const import (
STATE_ON,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
@@ -49,18 +48,20 @@ from .const import (
ECOBEE_MODEL_TO_NAME,
MANUFACTURER,
)
-from .util import ecobee_date, ecobee_time, is_indefinite_hold
+from .services import (
+ ATTR_COOL_TEMP,
+ ATTR_END_DATE,
+ ATTR_END_TIME,
+ ATTR_FAN_MIN_ON_TIME,
+ ATTR_FAN_MODE,
+ ATTR_HEAT_TEMP,
+ ATTR_START_DATE,
+ ATTR_START_TIME,
+ ATTR_VACATION_NAME,
+ _async_get_thermostats,
+)
+from .util import is_indefinite_hold
-ATTR_COOL_TEMP = "cool_temp"
-ATTR_END_DATE = "end_date"
-ATTR_END_TIME = "end_time"
-ATTR_FAN_MIN_ON_TIME = "fan_min_on_time"
-ATTR_FAN_MODE = "fan_mode"
-ATTR_HEAT_TEMP = "heat_temp"
-ATTR_RESUME_ALL = "resume_all"
-ATTR_START_DATE = "start_date"
-ATTR_START_TIME = "start_time"
-ATTR_VACATION_NAME = "vacation_name"
ATTR_DST_ENABLED = "dst_enabled"
ATTR_MIC_ENABLED = "mic_enabled"
ATTR_AUTO_AWAY = "auto_away"
@@ -68,7 +69,6 @@ ATTR_FOLLOW_ME = "follow_me"
ATTR_SENSOR_LIST = "device_ids"
ATTR_PRESET_MODE = "preset_mode"
-DEFAULT_RESUME_ALL = False
PRESET_AWAY_INDEFINITELY = "away_indefinitely"
PRESET_TEMPERATURE = "temp"
PRESET_VACATION = "vacation"
@@ -127,69 +127,11 @@ PRESET_TO_ECOBEE_HOLD = {
PRESET_HOLD_INDEFINITE: "indefinite",
}
-SERVICE_CREATE_VACATION = "create_vacation"
-SERVICE_DELETE_VACATION = "delete_vacation"
-SERVICE_RESUME_PROGRAM = "resume_program"
-SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time"
SERVICE_SET_DST_MODE = "set_dst_mode"
SERVICE_SET_MIC_MODE = "set_mic_mode"
SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes"
SERVICE_SET_SENSORS_USED_IN_CLIMATE = "set_sensors_used_in_climate"
-DTGROUP_START_INCLUSIVE_MSG = (
- f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together"
-)
-
-DTGROUP_END_INCLUSIVE_MSG = (
- f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together"
-)
-
-CREATE_VACATION_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_ENTITY_ID): cv.entity_id,
- vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)),
- vol.Required(ATTR_COOL_TEMP): vol.Coerce(float),
- vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float),
- vol.Inclusive(
- ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG
- ): ecobee_date,
- vol.Inclusive(
- ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG
- ): ecobee_time,
- vol.Inclusive(
- ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG
- ): ecobee_date,
- vol.Inclusive(
- ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG
- ): ecobee_time,
- vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"),
- vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All(
- int, vol.Range(min=0, max=60)
- ),
- }
-)
-
-DELETE_VACATION_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_ENTITY_ID): cv.entity_id,
- vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)),
- }
-)
-
-RESUME_PROGRAM_SCHEMA = vol.Schema(
- {
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean,
- }
-)
-
-SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema(
- {
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int),
- }
-)
-
SUPPORT_FLAGS = (
ClimateEntityFeature.TARGET_TEMPERATURE
@@ -226,96 +168,10 @@ async def async_setup_entry(
entities.append(Thermostat(data, index, thermostat, hass))
async_add_entities(entities, True)
+ _async_get_thermostats(hass).extend(entities)
platform = entity_platform.async_get_current_platform()
- def create_vacation_service(service: ServiceCall) -> None:
- """Create a vacation on the target thermostat."""
- entity_id = service.data[ATTR_ENTITY_ID]
-
- for thermostat in entities:
- if thermostat.entity_id == entity_id:
- thermostat.create_vacation(service.data)
- thermostat.schedule_update_ha_state(True)
- break
-
- def delete_vacation_service(service: ServiceCall) -> None:
- """Delete a vacation on the target thermostat."""
- entity_id = service.data[ATTR_ENTITY_ID]
- vacation_name = service.data[ATTR_VACATION_NAME]
-
- for thermostat in entities:
- if thermostat.entity_id == entity_id:
- thermostat.delete_vacation(vacation_name)
- thermostat.schedule_update_ha_state(True)
- break
-
- def fan_min_on_time_set_service(service: ServiceCall) -> None:
- """Set the minimum fan on time on the target thermostats."""
- entity_id = service.data.get(ATTR_ENTITY_ID)
- fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME]
-
- if entity_id:
- target_thermostats = [
- entity for entity in entities if entity.entity_id in entity_id
- ]
- else:
- target_thermostats = entities
-
- for thermostat in target_thermostats:
- thermostat.set_fan_min_on_time(str(fan_min_on_time))
-
- thermostat.schedule_update_ha_state(True)
-
- def resume_program_set_service(service: ServiceCall) -> None:
- """Resume the program on the target thermostats."""
- entity_id = service.data.get(ATTR_ENTITY_ID)
- resume_all = service.data.get(ATTR_RESUME_ALL)
-
- if entity_id:
- target_thermostats = [
- entity for entity in entities if entity.entity_id in entity_id
- ]
- else:
- target_thermostats = entities
-
- for thermostat in target_thermostats:
- thermostat.resume_program(resume_all)
-
- thermostat.schedule_update_ha_state(True)
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_CREATE_VACATION,
- create_vacation_service,
- schema=CREATE_VACATION_SCHEMA,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_DELETE_VACATION,
- delete_vacation_service,
- schema=DELETE_VACATION_SCHEMA,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_SET_FAN_MIN_ON_TIME,
- fan_min_on_time_set_service,
- schema=SET_FAN_MIN_ON_TIME_SCHEMA,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_RESUME_PROGRAM,
- resume_program_set_service,
- schema=RESUME_PROGRAM_SCHEMA,
- )
-
platform.async_register_entity_service(
SERVICE_SET_DST_MODE,
{vol.Required(ATTR_DST_ENABLED): cv.boolean},
diff --git a/homeassistant/components/ecobee/services.py b/homeassistant/components/ecobee/services.py
new file mode 100644
index 000000000000..86cdabe32055
--- /dev/null
+++ b/homeassistant/components/ecobee/services.py
@@ -0,0 +1,178 @@
+"""Services for the ecobee integration."""
+
+from typing import TYPE_CHECKING
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN
+from .util import ecobee_date, ecobee_time
+
+if TYPE_CHECKING:
+ from .climate import Thermostat
+
+ATTR_COOL_TEMP = "cool_temp"
+ATTR_END_DATE = "end_date"
+ATTR_END_TIME = "end_time"
+ATTR_FAN_MIN_ON_TIME = "fan_min_on_time"
+ATTR_FAN_MODE = "fan_mode"
+ATTR_HEAT_TEMP = "heat_temp"
+ATTR_RESUME_ALL = "resume_all"
+ATTR_START_DATE = "start_date"
+ATTR_START_TIME = "start_time"
+ATTR_VACATION_NAME = "vacation_name"
+
+DEFAULT_RESUME_ALL = False
+
+DATA_THERMOSTATS = "thermostats"
+
+SERVICE_CREATE_VACATION = "create_vacation"
+SERVICE_DELETE_VACATION = "delete_vacation"
+SERVICE_RESUME_PROGRAM = "resume_program"
+SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time"
+
+DTGROUP_START_INCLUSIVE_MSG = (
+ f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together"
+)
+
+DTGROUP_END_INCLUSIVE_MSG = (
+ f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together"
+)
+
+CREATE_VACATION_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)),
+ vol.Required(ATTR_COOL_TEMP): vol.Coerce(float),
+ vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float),
+ vol.Inclusive(
+ ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG
+ ): ecobee_date,
+ vol.Inclusive(
+ ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG
+ ): ecobee_time,
+ vol.Inclusive(
+ ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG
+ ): ecobee_date,
+ vol.Inclusive(
+ ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG
+ ): ecobee_time,
+ vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"),
+ vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All(
+ int, vol.Range(min=0, max=60)
+ ),
+ }
+)
+
+DELETE_VACATION_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)),
+ }
+)
+
+RESUME_PROGRAM_SCHEMA = vol.Schema(
+ {
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean,
+ }
+)
+
+SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema(
+ {
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int),
+ }
+)
+
+
+@callback
+def _async_get_thermostats(hass: HomeAssistant) -> list[Thermostat]:
+ """Return loaded ecobee thermostat entities."""
+ # pylint: disable-next=home-assistant-use-runtime-data
+ return hass.data[DOMAIN][DATA_THERMOSTATS]
+
+
+def _create_vacation_service(call: ServiceCall) -> None:
+ """Create a vacation on the target thermostat."""
+ for thermostat in _async_get_thermostats(call.hass):
+ if thermostat.entity_id == call.data[ATTR_ENTITY_ID]:
+ thermostat.create_vacation(call.data)
+ thermostat.schedule_update_ha_state(True)
+ break
+
+
+def _delete_vacation_service(call: ServiceCall) -> None:
+ """Delete a vacation on the target thermostat."""
+ for thermostat in _async_get_thermostats(call.hass):
+ if thermostat.entity_id == call.data[ATTR_ENTITY_ID]:
+ thermostat.delete_vacation(call.data[ATTR_VACATION_NAME])
+ thermostat.schedule_update_ha_state(True)
+ break
+
+
+def _fan_min_on_time_set_service(call: ServiceCall) -> None:
+ """Set the minimum fan on time on the target thermostats."""
+ entity_id = call.data.get(ATTR_ENTITY_ID)
+ thermostats = _async_get_thermostats(call.hass)
+ if entity_id:
+ thermostats = [
+ thermostat
+ for thermostat in thermostats
+ if thermostat.entity_id in entity_id
+ ]
+
+ for thermostat in thermostats:
+ thermostat.set_fan_min_on_time(str(call.data[ATTR_FAN_MIN_ON_TIME]))
+ thermostat.schedule_update_ha_state(True)
+
+
+def _resume_program_set_service(call: ServiceCall) -> None:
+ """Resume the program on the target thermostats."""
+ entity_id = call.data.get(ATTR_ENTITY_ID)
+ thermostats = _async_get_thermostats(call.hass)
+ if entity_id:
+ thermostats = [
+ thermostat
+ for thermostat in thermostats
+ if thermostat.entity_id in entity_id
+ ]
+
+ for thermostat in thermostats:
+ thermostat.resume_program(call.data.get(ATTR_RESUME_ALL))
+ thermostat.schedule_update_ha_state(True)
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register ecobee services."""
+ # pylint: disable-next=home-assistant-use-runtime-data
+ hass.data.setdefault(DOMAIN, {})[DATA_THERMOSTATS] = []
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_CREATE_VACATION,
+ _create_vacation_service,
+ schema=CREATE_VACATION_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_DELETE_VACATION,
+ _delete_vacation_service,
+ schema=DELETE_VACATION_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_FAN_MIN_ON_TIME,
+ _fan_min_on_time_set_service,
+ schema=SET_FAN_MIN_ON_TIME_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_RESUME_PROGRAM,
+ _resume_program_set_service,
+ schema=RESUME_PROGRAM_SCHEMA,
+ )
diff --git a/homeassistant/components/edifier_infrared/button.py b/homeassistant/components/edifier_infrared/button.py
index 8240e357cdcb..10cd8d2e481e 100644
--- a/homeassistant/components/edifier_infrared/button.py
+++ b/homeassistant/components/edifier_infrared/button.py
@@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
+from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
@@ -141,6 +142,48 @@ COMMAND_SET_BUTTONS: dict[
command_code=EdifierRC20GCode.COAX,
),
),
+ EdifierCommandSet.S3000PRO: (
+ EdifierIrButtonEntityDescription(
+ key="usb",
+ translation_key="usb",
+ command_code=EdifierS3000ProCode.USB,
+ ),
+ EdifierIrButtonEntityDescription(
+ key="bluetooth",
+ translation_key="bluetooth",
+ command_code=EdifierS3000ProCode.BLUETOOTH,
+ ),
+ EdifierIrButtonEntityDescription(
+ key="line_bal",
+ translation_key="line_bal",
+ command_code=EdifierS3000ProCode.LINE_BAL,
+ ),
+ EdifierIrButtonEntityDescription(
+ key="opt_coax",
+ translation_key="opt_coax",
+ command_code=EdifierS3000ProCode.OPT_COAX,
+ ),
+ EdifierIrButtonEntityDescription(
+ key="eq_monitor",
+ translation_key="eq_monitor",
+ command_code=EdifierS3000ProCode.EQ_MONITOR,
+ ),
+ EdifierIrButtonEntityDescription(
+ key="eq_dynamic",
+ translation_key="eq_dynamic",
+ command_code=EdifierS3000ProCode.EQ_DYNAMIC,
+ ),
+ EdifierIrButtonEntityDescription(
+ key="eq_classic",
+ translation_key="eq_classic",
+ command_code=EdifierS3000ProCode.EQ_CLASSIC,
+ ),
+ EdifierIrButtonEntityDescription(
+ key="eq_vocal",
+ translation_key="eq_vocal",
+ command_code=EdifierS3000ProCode.EQ_VOCAL,
+ ),
+ ),
}
diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py
index 057f71a7c510..4fd4b959d7b2 100644
--- a/homeassistant/components/edifier_infrared/const.py
+++ b/homeassistant/components/edifier_infrared/const.py
@@ -5,6 +5,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
+from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode
DOMAIN = "edifier_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
@@ -16,4 +17,5 @@ type EdifierCode = (
| EdifierR1280TCode
| EdifierS360DBCode
| EdifierRC20GCode
+ | EdifierS3000ProCode
)
diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py
index 6944e05b9156..8c9963e80e9d 100644
--- a/homeassistant/components/edifier_infrared/media_player.py
+++ b/homeassistant/components/edifier_infrared/media_player.py
@@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
+from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.components.media_player import (
@@ -92,6 +93,19 @@ COMMAND_SET_COMMANDS: dict[
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,),
},
+ EdifierCommandSet.S3000PRO: {
+ MediaPlayerEntityFeature.TURN_ON: (EdifierS3000ProCode.POWER,),
+ MediaPlayerEntityFeature.TURN_OFF: (EdifierS3000ProCode.POWER,),
+ MediaPlayerEntityFeature.VOLUME_STEP: (
+ (EdifierS3000ProCode.VOLUME_UP,),
+ (EdifierS3000ProCode.VOLUME_DOWN,),
+ ),
+ MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierS3000ProCode.MUTE,),
+ MediaPlayerEntityFeature.PLAY: (EdifierS3000ProCode.PLAY_PAUSE,),
+ MediaPlayerEntityFeature.PAUSE: (EdifierS3000ProCode.PLAY_PAUSE,),
+ MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS3000ProCode.NEXT,),
+ MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS3000ProCode.PREVIOUS,),
+ },
}
diff --git a/homeassistant/components/edifier_infrared/strings.json b/homeassistant/components/edifier_infrared/strings.json
index 28235e17699b..0ed3c9e2c2a8 100644
--- a/homeassistant/components/edifier_infrared/strings.json
+++ b/homeassistant/components/edifier_infrared/strings.json
@@ -30,6 +30,18 @@
"coax": {
"name": "Coaxial"
},
+ "eq_classic": {
+ "name": "Classic EQ"
+ },
+ "eq_dynamic": {
+ "name": "Dynamic EQ"
+ },
+ "eq_monitor": {
+ "name": "Monitor EQ"
+ },
+ "eq_vocal": {
+ "name": "Vocal EQ"
+ },
"fx_off": {
"name": "FX off"
},
@@ -42,11 +54,20 @@
"line_2": {
"name": "Line 2"
},
+ "line_bal": {
+ "name": "Line / Balanced"
+ },
+ "opt_coax": {
+ "name": "Optical / Coaxial"
+ },
"optical": {
"name": "Optical"
},
"pc": {
"name": "PC"
+ },
+ "usb": {
+ "name": "USB"
}
}
}
diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json
index 4970b73838cc..d5e675fc7760 100644
--- a/homeassistant/components/edl21/manifest.json
+++ b/homeassistant/components/edl21/manifest.json
@@ -3,6 +3,7 @@
"name": "EDL21",
"codeowners": [],
"config_flow": true,
+ "dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/edl21",
"integration_type": "device",
"iot_class": "local_push",
diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py
index e85ff73a8751..3d5ba506df96 100644
--- a/homeassistant/components/elgato/light.py
+++ b/homeassistant/components/elgato/light.py
@@ -35,7 +35,7 @@ class ElgatoLight(ElgatoEntity, LightEntity):
_attr_name = None
_attr_min_color_temp_kelvin = 2900 # 344 Mireds
- _attr_max_color_temp_kelvin = 7000 # 143 Mireds
+ _attr_max_color_temp_kelvin = 6993 # 143 Mireds
def __init__(self, coordinator: ElgatoDataUpdateCoordinator) -> None:
"""Initialize Elgato Light."""
@@ -100,7 +100,7 @@ class ElgatoLight(ElgatoEntity, LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self.coordinator.client.light(on=False)
- await self.coordinator.async_request_refresh()
+ await self.coordinator.async_refresh()
@elgato_exception_handler
@override
@@ -143,7 +143,7 @@ class ElgatoLight(ElgatoEntity, LightEntity):
saturation=saturation,
temperature=temperature,
)
- await self.coordinator.async_request_refresh()
+ await self.coordinator.async_refresh()
@elgato_exception_handler
async def async_identify(self) -> None:
diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py
index 289de2c7133a..e85a9a303b6c 100644
--- a/homeassistant/components/elkm1/alarm_control_panel.py
+++ b/homeassistant/components/elkm1/alarm_control_panel.py
@@ -10,9 +10,9 @@ from elkm1_lib.keypads import Keypad
import voluptuous as vol
from homeassistant.components.alarm_control_panel import (
- ATTR_CHANGED_BY,
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
+ AlarmControlPanelEntityStateAttribute,
AlarmControlPanelState,
CodeFormat,
)
@@ -136,8 +136,10 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
self._changed_by_time = last_state.attributes[ATTR_CHANGED_BY_TIME]
if ATTR_CHANGED_BY_ID in last_state.attributes:
self._changed_by_id = last_state.attributes[ATTR_CHANGED_BY_ID]
- if ATTR_CHANGED_BY in last_state.attributes:
- self._changed_by = last_state.attributes[ATTR_CHANGED_BY]
+ if AlarmControlPanelEntityStateAttribute.CHANGED_BY in last_state.attributes:
+ self._changed_by = last_state.attributes[
+ AlarmControlPanelEntityStateAttribute.CHANGED_BY
+ ]
def _watch_keypad(self, keypad: Element, changeset: dict[str, Any]) -> None:
assert isinstance(keypad, Keypad)
diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py
index 2e920cf6cadd..0e214728c82d 100644
--- a/homeassistant/components/emby/media_player.py
+++ b/homeassistant/components/emby/media_player.py
@@ -18,7 +18,6 @@ from homeassistant.const import (
CONF_HOST,
CONF_PORT,
CONF_SSL,
- DEVICE_DEFAULT_NAME,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
)
@@ -179,7 +178,7 @@ class EmbyDevice(MediaPlayerEntity):
@override
def name(self):
"""Return the name of the device."""
- return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME
+ return f"Emby {self.device.name}"
@property
@override
diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py
index 103ab28f480e..ba0de3a35ac0 100644
--- a/homeassistant/components/emulated_hue/hue_api.py
+++ b/homeassistant/components/emulated_hue/hue_api.py
@@ -50,7 +50,6 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import (
ATTR_ENTITY_ID,
- ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
@@ -62,6 +61,7 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
+ EntityStateAttribute,
)
from homeassistant.core import Event, EventStateChangedData, State
from homeassistant.helpers.event import async_track_state_change_event
@@ -382,9 +382,16 @@ class HueOneLightChangeView(HomeAssistantView):
return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST)
# Get the entity's supported features
- entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ entity_features = entity.attributes.get(
+ EntityStateAttribute.SUPPORTED_FEATURES, 0
+ )
if entity.domain == light.DOMAIN:
- color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or []
+ color_modes = (
+ entity.attributes.get(
+ light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES
+ )
+ or []
+ )
# Parse the request
parsed: dict[str, Any] = {
@@ -769,7 +776,10 @@ def _entity_unique_id(entity_id: str) -> str:
def state_to_json(config: Config, state: State) -> dict[str, Any]:
"""Convert an entity to its Hue bridge JSON representation."""
- color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or []
+ color_modes = (
+ state.attributes.get(light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES)
+ or []
+ )
unique_id = _entity_unique_id(state.entity_id)
state_dict = get_entity_state_dict(config, state)
@@ -865,7 +875,7 @@ def state_supports_hue_brightness(
return light.brightness_supported(color_modes)
if not (required_feature := DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN.get(domain)):
return False
- features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ features = state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
enum = ENTITY_FEATURES_BY_DOMAIN[domain]
features = enum(features) if type(features) is int else features
return required_feature in features
diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json
index bc7ed9de5822..c551a3149eb8 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.14.1"]
+ "requirements": ["sense-energy==0.14.3"]
}
diff --git a/homeassistant/components/energieleser/__init__.py b/homeassistant/components/energieleser/__init__.py
index f9167316eca0..5533dd6e91dc 100644
--- a/homeassistant/components/energieleser/__init__.py
+++ b/homeassistant/components/energieleser/__init__.py
@@ -4,8 +4,10 @@ from energieleser import EnergieleserClient
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from .const import DOMAIN
from .coordinator import EnergieleserConfigEntry, EnergieleserCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -30,4 +32,5 @@ async def async_unload_entry(
hass: HomeAssistant, entry: EnergieleserConfigEntry
) -> bool:
"""Unload an energieleser config entry."""
+ ir.async_delete_issue(hass, DOMAIN, f"pin_locked_{entry.entry_id}")
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/energieleser/config_flow.py b/homeassistant/components/energieleser/config_flow.py
index 7a3a38ed1b81..4fccd3beec17 100755
--- a/homeassistant/components/energieleser/config_flow.py
+++ b/homeassistant/components/energieleser/config_flow.py
@@ -141,6 +141,43 @@ class EnergieleserConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfiguration flow initialized by the user."""
+ entry = self._get_reconfigure_entry()
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ host = user_input[CONF_HOST]
+ client = EnergieleserClient(
+ host=host, session=async_get_clientsession(self.hass)
+ )
+ try:
+ device = await client.get_device()
+ except EnergieleserConnectionError:
+ errors["base"] = "cannot_connect"
+ except EnergieleserUnknownDeviceError:
+ errors["base"] = "unknown_device_type"
+ except EnergieleserError:
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(device.device_id)
+ self._abort_if_unique_id_mismatch(reason="wrong_device")
+ return self.async_update_reload_and_abort(
+ entry,
+ data_updates={CONF_HOST: host},
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=STEP_USER_SCHEMA,
+ suggested_values=entry.data | (user_input or {}),
+ ),
+ errors=errors,
+ )
+
def _create_entry(
self, host: str, title: str, device_id: str, sw_version: str | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/energieleser/coordinator.py b/homeassistant/components/energieleser/coordinator.py
index bb4a06e5a4e6..6c026a69fa47 100755
--- a/homeassistant/components/energieleser/coordinator.py
+++ b/homeassistant/components/energieleser/coordinator.py
@@ -9,11 +9,13 @@ from energieleser import (
EnergieleserDevice,
EnergieleserError,
EnergieleserUnknownDeviceError,
+ StromleserOneDevice,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
@@ -52,14 +54,45 @@ class EnergieleserCoordinator(DataUpdateCoordinator[EnergieleserDevice]):
device = await self.client.get_device()
except EnergieleserUnknownDeviceError as err:
raise UpdateFailed(
- f"Unknown or unsupported device type for {self.device_id}: {err}"
+ translation_domain=DOMAIN,
+ translation_key="unknown_device",
+ translation_placeholders={
+ "device_id": self.device_id,
+ },
) from err
except EnergieleserConnectionError as err:
raise UpdateFailed(
- f"Cannot connect to energieleser device {self.device_id}: {err}"
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ translation_placeholders={
+ "device_id": self.device_id,
+ },
) from err
except EnergieleserError as err:
raise UpdateFailed(
- f"Error communicating with energieleser device {self.device_id}: {err}"
+ translation_domain=DOMAIN,
+ translation_key="communication_error",
+ translation_placeholders={
+ "device_id": self.device_id,
+ },
) from err
+ if isinstance(device, StromleserOneDevice):
+ issue_id = f"pin_locked_{self.config_entry.entry_id}"
+ if device.pin_locked:
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ issue_id,
+ is_fixable=False,
+ is_persistent=False,
+ learn_more_url="https://docs.energieleser.de/en/docs/stromleser-one/installation/preparation",
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="meter_locked",
+ translation_placeholders={
+ "device_name": self.config_entry.title,
+ },
+ )
+ else:
+ ir.async_delete_issue(self.hass, DOMAIN, issue_id)
+
return device
diff --git a/homeassistant/components/energieleser/diagnostics.py b/homeassistant/components/energieleser/diagnostics.py
new file mode 100755
index 000000000000..792297e8c58b
--- /dev/null
+++ b/homeassistant/components/energieleser/diagnostics.py
@@ -0,0 +1,25 @@
+"""Diagnostics support for energieleser."""
+
+import dataclasses
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_DEVICE_ID
+from homeassistant.core import HomeAssistant
+
+from .coordinator import EnergieleserConfigEntry
+
+TO_REDACT = {CONF_DEVICE_ID, "fabrication_number"}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: EnergieleserConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator = entry.runtime_data
+
+ device_data = coordinator.data
+
+ device_data_dict = dataclasses.asdict(device_data)
+
+ return async_redact_data(device_data_dict, TO_REDACT)
diff --git a/homeassistant/components/energieleser/manifest.json b/homeassistant/components/energieleser/manifest.json
index b87071078e1a..160cffb2c9d3 100644
--- a/homeassistant/components/energieleser/manifest.json
+++ b/homeassistant/components/energieleser/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
- "requirements": ["energieleser==0.1.4"],
+ "requirements": ["energieleser==0.1.5"],
"zeroconf": [
{
"type": "_stromleser._tcp.local."
diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml
index 64f37f0fcd8c..ea4d7d13f731 100644
--- a/homeassistant/components/energieleser/quality_scale.yaml
+++ b/homeassistant/components/energieleser/quality_scale.yaml
@@ -49,7 +49,7 @@ rules:
# Gold
devices: done
- diagnostics: todo
+ diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
@@ -66,10 +66,10 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
- exception-translations: todo
+ exception-translations: done
icon-translations: todo
- reconfiguration-flow: todo
- repair-issues: todo
+ reconfiguration-flow: done
+ repair-issues: done
stale-devices:
status: exempt
comment: One device per config entry; the device is removed when the entry is removed.
diff --git a/homeassistant/components/energieleser/strings.json b/homeassistant/components/energieleser/strings.json
index 2138e95ee459..370ec96f4b9b 100755
--- a/homeassistant/components/energieleser/strings.json
+++ b/homeassistant/components/energieleser/strings.json
@@ -3,8 +3,10 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "unknown_device_type": "This isn't a supported energieleser product. Please check that the device is a stromleser, gasleser, wasserleser, or wärmeleser."
+ "unknown_device_type": "This isn't a supported energieleser product. Please check that the device is a stromleser, gasleser, wasserleser, or wärmeleser.",
+ "wrong_device": "The device at this IP address does not match the originally configured device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -13,6 +15,16 @@
},
"flow_title": "{device_type} · {host}",
"step": {
+ "reconfigure": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "[%key:component::energieleser::config::step::user::data_description::host%]"
+ },
+ "description": "Update the IP address or hostname of your energieleser device.",
+ "title": "Reconfigure energieleser"
+ },
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -80,5 +92,22 @@
"name": "Water today"
}
}
+ },
+ "exceptions": {
+ "communication_error": {
+ "message": "An error occurred while communicating with the device {device_id}"
+ },
+ "connection_error": {
+ "message": "An error occurred while connecting to the device {device_id}"
+ },
+ "unknown_device": {
+ "message": "The device type for {device_id} is unknown or unsupported"
+ }
+ },
+ "issues": {
+ "meter_locked": {
+ "description": "The electricity meter connected to {device_name} is not providing high-resolution data. You need to unlock the physical meter by entering the PIN (provided by your electricity company or grid operator) directly on the meter. Once the meter is unlocked, high-resolution data will be provided and this issue will resolve itself automatically. See the linked instructions for details on how to enter the PIN.",
+ "title": "Meter PIN Required"
+ }
}
}
diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py
index b893ed097880..85fd93d59fda 100644
--- a/homeassistant/components/energy/sensor.py
+++ b/homeassistant/components/energy/sensor.py
@@ -8,17 +8,17 @@ import logging
from typing import Any, Final, Literal, cast, override
from homeassistant.components.sensor import (
- ATTR_LAST_RESET,
- ATTR_STATE_CLASS,
SensorDeviceClass,
SensorEntity,
+ SensorEntityCapabilityAttribute,
+ SensorEntityStateAttribute,
SensorStateClass,
)
from homeassistant.components.sensor.recorder import ( # pylint: disable=home-assistant-component-root-import
reset_detected,
)
from homeassistant.const import (
- ATTR_UNIT_OF_MEASUREMENT,
+ EntityStateAttribute,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
@@ -436,7 +436,9 @@ class EnergyCostSensor(SensorEntity):
if energy_state is None:
return
- state_class = energy_state.attributes.get(ATTR_STATE_CLASS)
+ state_class = energy_state.attributes.get(
+ SensorEntityCapabilityAttribute.STATE_CLASS
+ )
if state_class not in SUPPORTED_STATE_CLASSES:
if not self._wrong_state_class_reported:
self._wrong_state_class_reported = True
@@ -450,7 +452,7 @@ class EnergyCostSensor(SensorEntity):
# last_reset must be set if the sensor is SensorStateClass.MEASUREMENT
if (
state_class == SensorStateClass.MEASUREMENT
- and ATTR_LAST_RESET not in energy_state.attributes
+ and SensorEntityStateAttribute.LAST_RESET not in energy_state.attributes
):
return
@@ -478,22 +480,28 @@ class EnergyCostSensor(SensorEntity):
if energy_price is None:
return
- energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ energy_unit: str | None = energy_state.attributes.get(
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
+ )
if energy_unit is None or energy_unit not in valid_units:
if not self._wrong_unit_reported:
self._wrong_unit_reported = True
_LOGGER.warning(
"Found unexpected unit %s for %s",
- energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT),
+ energy_state.attributes.get(
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
+ ),
energy_state.entity_id,
)
return
if (
state_class != SensorStateClass.TOTAL_INCREASING
- and energy_state.attributes.get(ATTR_LAST_RESET)
- != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET)
+ and energy_state.attributes.get(SensorEntityStateAttribute.LAST_RESET)
+ != self._last_energy_sensor_state.attributes.get(
+ SensorEntityStateAttribute.LAST_RESET
+ )
) or (
state_class == SensorStateClass.TOTAL_INCREASING
and reset_detected(
@@ -544,7 +552,7 @@ class EnergyCostSensor(SensorEntity):
energy_price = float(energy_price_state.state)
energy_price_unit: str | None = energy_price_state.attributes.get(
- ATTR_UNIT_OF_MEASUREMENT, ""
+ EntityStateAttribute.UNIT_OF_MEASUREMENT, ""
).partition("/")[2]
# For backwards compatibility we don't validate the unit of the price
@@ -731,7 +739,7 @@ class EnergyPowerSensor(SensorEntity):
return
self._attr_native_unit_of_measurement = source_state.attributes.get(
- ATTR_UNIT_OF_MEASUREMENT
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
)
self._attr_native_value = value * -1
@@ -756,8 +764,12 @@ class EnergyPowerSensor(SensorEntity):
return
# Get units from state attributes
- discharge_unit = discharge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
- charge_unit = charge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ discharge_unit = discharge_state.attributes.get(
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
+ )
+ charge_unit = charge_state.attributes.get(
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
+ )
# Convert to Watts if units are present
if discharge_unit:
diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py
index fe8eee2ba108..3c323c7884ea 100644
--- a/homeassistant/components/energy/validate.py
+++ b/homeassistant/components/energy/validate.py
@@ -6,9 +6,9 @@ import functools
from homeassistant.components import recorder, sensor
from homeassistant.const import (
- ATTR_DEVICE_CLASS,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
@@ -231,13 +231,13 @@ def _async_validate_stat_common(
if check_negative and current_value is not None and current_value < 0:
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
- device_class = state.attributes.get(ATTR_DEVICE_CLASS)
+ device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if device_class not in allowed_device_classes:
issues.add_issue(
hass, "entity_unexpected_device_class", entity_id, device_class
)
else:
- unit = state.attributes.get("unit_of_measurement")
+ unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if device_class and unit not in allowed_units.get(device_class, []):
issues.add_issue(hass, unit_error, entity_id, unit)
@@ -272,7 +272,9 @@ def _async_validate_usage_stat(
state = hass.states.get(entity_id)
assert state is not None
- state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
+ state_class = state.attributes.get(
+ sensor.SensorEntityCapabilityAttribute.STATE_CLASS
+ )
allowed_state_classes = [
sensor.SensorStateClass.MEASUREMENT,
@@ -310,7 +312,7 @@ def _async_validate_price_entity(
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
return
- unit = state.attributes.get("unit_of_measurement")
+ unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if unit is None or not unit.endswith(allowed_units):
issues.add_issue(hass, unit_error, entity_id, unit)
@@ -343,7 +345,9 @@ def _async_validate_power_stat(
state = hass.states.get(entity_id)
assert state is not None
- state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
+ state_class = state.attributes.get(
+ sensor.SensorEntityCapabilityAttribute.STATE_CLASS
+ )
if state_class != sensor.SensorStateClass.MEASUREMENT:
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
@@ -372,7 +376,9 @@ def _async_validate_cost_stat(
issues.add_issue(hass, "entity_not_defined", stat_id)
return
- state_class = state.attributes.get("state_class")
+ state_class = state.attributes.get(
+ sensor.SensorEntityCapabilityAttribute.STATE_CLASS
+ )
supported_state_classes = [
sensor.SensorStateClass.MEASUREMENT,
diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py
index 0babd95a2073..a317cfb121bf 100644
--- a/homeassistant/components/enphase_envoy/config_flow.py
+++ b/homeassistant/components/enphase_envoy/config_flow.py
@@ -5,7 +5,8 @@ import logging
from typing import TYPE_CHECKING, Any, override
from awesomeversion import AwesomeVersion
-from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError
+import jwt
+from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError, EnvoyTokenAuth
import voluptuous as vol
from homeassistant.config_entries import (
@@ -25,8 +26,11 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
+from homeassistant.util import dt as dt_util
from .const import (
+ ACCESS_TOKEN_LOGIN_URL,
+ CONF_MANUAL_TOKEN,
DOMAIN,
INVALID_AUTH_ERRORS,
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES,
@@ -46,17 +50,44 @@ INSTALLER_AUTH_USERNAME = "installer"
AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN}
+UNKNOWN_TOKEN_TEXT = "?"
+
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}
+def token_lifetime(token: str) -> str:
+ """Return token lifetime in days."""
+ days_left = UNKNOWN_TOKEN_TEXT
+ try:
+ jwt_payload = jwt.decode(token, options={"verify_signature": False})
+ exp = jwt_payload.get("exp")
+ if exp is not None:
+ days_left = str(int((int(exp) - dt_util.utcnow().timestamp()) / 86400))
+ except jwt.PyJWTError, KeyError, TypeError, ValueError:
+ days_left = UNKNOWN_TOKEN_TEXT
+ return days_left
+
+
+def descriptions(
+ serial: str, token_days_left: str = UNKNOWN_TOKEN_TEXT
+) -> dict[str, str]:
+ """Build description placeholders."""
+ return {
+ CONF_SERIAL: serial,
+ "enphase_url": ACCESS_TOKEN_LOGIN_URL,
+ "token_life": token_days_left,
+ }
+
+
async def validate_input(
hass: HomeAssistant,
host: str,
username: str,
password: str,
+ token: str | None,
errors: dict[str, str],
description_placeholders: dict[str, str],
) -> Envoy:
@@ -64,7 +95,7 @@ async def validate_input(
envoy = Envoy(host, async_get_clientsession(hass, verify_ssl=False))
try:
await envoy.setup()
- await envoy.authenticate(username=username, password=password)
+ await envoy.authenticate(username=username, password=password, token=token)
except INVALID_AUTH_ERRORS as e:
errors["base"] = "invalid_auth"
description_placeholders["reason"] = str(e)
@@ -88,6 +119,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
self.ip_address: str | None = None
self.username = None
self.protovers: str | None = None
+ self.manual_token: bool = False
@staticmethod
@callback
@@ -118,10 +150,18 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
):
default_username = INSTALLER_AUTH_USERNAME
- schema[
- vol.Optional(CONF_USERNAME, default=self.username or default_username)
- ] = str
- schema[vol.Optional(CONF_PASSWORD, default="")] = str
+ if self.manual_token:
+ # in manual token entry mode show token input field
+ schema[vol.Optional(CONF_TOKEN, default="")] = str
+ else:
+ # in automatic token mode show username and password inputs
+ schema[
+ vol.Optional(CONF_USERNAME, default=self.username or default_username)
+ ] = str
+ schema[vol.Optional(CONF_PASSWORD, default="")] = str
+
+ # option to switch between automatic and manual token entry modes
+ schema[vol.Optional(CONF_MANUAL_TOKEN, default=self.manual_token)] = bool
return vol.Schema(schema)
@@ -194,28 +234,48 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
+ token_days_left: str = UNKNOWN_TOKEN_TEXT
- if user_input is not None:
- await validate_input(
+ if token := reauth_entry.data.get(CONF_TOKEN, ""):
+ token_days_left = token_lifetime(token)
+
+ if user_input is None:
+ # remember current manual_token setting to detect switch between modes
+ self.manual_token = reauth_entry.data.get(CONF_MANUAL_TOKEN, False)
+ elif user_input.get(CONF_MANUAL_TOKEN) != self.manual_token:
+ # user is switching between manual and automatic token entry mode
+ # display the form in the other mode, no configuration update yet
+ self.manual_token = user_input[CONF_MANUAL_TOKEN]
+ else:
+ envoy = await validate_input(
self.hass,
reauth_entry.data[CONF_HOST],
- user_input[CONF_USERNAME],
- user_input[CONF_PASSWORD],
+ user_input.get(CONF_USERNAME, ""),
+ user_input.get(CONF_PASSWORD, ""),
+ token := user_input.get(CONF_TOKEN, "") or None,
errors,
description_placeholders,
)
if not errors:
+ # successful authentication, update config
return self.async_update_reload_and_abort(
reauth_entry,
- data_updates=user_input,
+ data_updates=user_input
+ | (
+ {CONF_TOKEN: envoy.auth.token}
+ if isinstance(envoy.auth, EnvoyTokenAuth)
+ else {}
+ ),
)
+ if token:
+ token_days_left = token_lifetime(token)
serial = reauth_entry.unique_id or "-"
self.context["title_placeholders"] = {
CONF_SERIAL: serial,
CONF_HOST: reauth_entry.data[CONF_HOST],
}
- description_placeholders["serial"] = serial
+ description_placeholders.update(descriptions(serial, token_days_left))
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
@@ -238,44 +298,66 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
host = (user_input or {}).get(CONF_HOST) or self.ip_address or ""
+ token_days_left: str = UNKNOWN_TOKEN_TEXT
+
+ if user_input and (token := user_input.get(CONF_TOKEN, "")):
+ token_days_left = token_lifetime(token)
if user_input is not None:
- envoy = await validate_input(
- self.hass,
- host,
- user_input[CONF_USERNAME],
- user_input[CONF_PASSWORD],
- errors,
- description_placeholders,
- )
- if not errors:
- name = self._async_envoy_name()
-
- if not self.unique_id:
- await self.async_set_unique_id(envoy.serial_number)
+ if (
+ manual_mode := user_input.get(CONF_MANUAL_TOKEN, False)
+ ) != self.manual_token:
+ # for new config self.manual_token starts default as false
+ # user is switching between manual and automatic token entry mode
+ # show form again in other mode, no configuration update yet
+ self.manual_token = manual_mode
+ else:
+ envoy = await validate_input(
+ self.hass,
+ host,
+ user_input.get(CONF_USERNAME, ""),
+ user_input.get(CONF_PASSWORD, ""),
+ token := user_input.get(CONF_TOKEN, "") or None,
+ errors,
+ description_placeholders,
+ )
+ if not errors:
name = self._async_envoy_name()
-
- if self.unique_id:
- # If envoy exists in configuration update fields and exit
- self._abort_if_unique_id_configured(
- {
- CONF_HOST: host,
- CONF_USERNAME: user_input[CONF_USERNAME],
- CONF_PASSWORD: user_input[CONF_PASSWORD],
- },
- error="reauth_successful",
+ # successful authentication, store token in config
+ token_update = (
+ {CONF_TOKEN: envoy.auth.token}
+ if isinstance(envoy.auth, EnvoyTokenAuth)
+ else {}
)
- # CONF_NAME is still set for legacy backwards compatibility
- return self.async_create_entry(
- title=name, data={CONF_HOST: host, CONF_NAME: name} | user_input
- )
+ if not self.unique_id:
+ await self.async_set_unique_id(envoy.serial_number)
+ name = self._async_envoy_name()
+
+ if self.unique_id:
+ # If envoy exists in configuration update fields and exit
+ self._abort_if_unique_id_configured(
+ {
+ CONF_HOST: host,
+ CONF_USERNAME: user_input.get(CONF_USERNAME, ""),
+ CONF_PASSWORD: user_input.get(CONF_PASSWORD, ""),
+ CONF_MANUAL_TOKEN: self.manual_token,
+ }
+ | token_update,
+ error="reauth_successful",
+ )
+
+ # CONF_NAME is still set for legacy backwards compatibility
+ return self.async_create_entry(
+ title=name, data={CONF_NAME: name} | user_input | token_update
+ )
if self.unique_id:
self.context["title_placeholders"] = {
CONF_SERIAL: self.unique_id,
CONF_HOST: host,
}
+ description_placeholders.update(descriptions("", token_days_left))
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
@@ -293,20 +375,29 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
reconfigure_entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
+ token_days_left: str = UNKNOWN_TOKEN_TEXT
- if user_input is not None:
- host: str = user_input[CONF_HOST]
- username: str = user_input[CONF_USERNAME]
- password: str = user_input[CONF_PASSWORD]
+ if token := reconfigure_entry.data.get(CONF_TOKEN, ""):
+ token_days_left = token_lifetime(token)
+ if user_input is None:
+ # remember current manual_token setting to detect switch between modes
+ self.manual_token = reconfigure_entry.data.get(CONF_MANUAL_TOKEN, False)
+ elif user_input.get(CONF_MANUAL_TOKEN) != self.manual_token:
+ # user switches between manual and automatic token entry mode
+ # show form again on other mode, no configuration update yet
+ self.manual_token = user_input[CONF_MANUAL_TOKEN]
+ else:
envoy = await validate_input(
self.hass,
- host,
- username,
- password,
+ host := user_input[CONF_HOST],
+ username := user_input.get(CONF_USERNAME, ""),
+ password := user_input.get(CONF_PASSWORD, ""),
+ token := user_input.get(CONF_TOKEN, "") or None,
errors,
description_placeholders,
)
if not errors:
+ # successful authentication, store token in config
await self.async_set_unique_id(envoy.serial_number)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
@@ -315,16 +406,23 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: host,
CONF_USERNAME: username,
CONF_PASSWORD: password,
- },
+ CONF_MANUAL_TOKEN: self.manual_token,
+ }
+ | (
+ {CONF_TOKEN: envoy.auth.token}
+ if isinstance(envoy.auth, EnvoyTokenAuth)
+ else {}
+ ),
)
+ if token:
+ token_days_left = token_lifetime(token)
serial = reconfigure_entry.unique_id or "-"
self.context["title_placeholders"] = {
CONF_SERIAL: serial,
CONF_HOST: reconfigure_entry.data[CONF_HOST],
}
- description_placeholders["serial"] = serial
-
+ description_placeholders.update(descriptions(serial, token_days_left))
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py
index d5f46a66650a..95b66c9fede7 100644
--- a/homeassistant/components/enphase_envoy/const.py
+++ b/homeassistant/components/enphase_envoy/const.py
@@ -16,6 +16,9 @@ PLATFORMS = [
INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired)
+ACCESS_TOKEN_LOGIN_URL = "https://entrez.enphaseenergy.com"
+CONF_MANUAL_TOKEN = "use_manual_token"
+
SETUP_RETRY_TIMEOUT = 50
OPERATIONAL_RETRY_TIMEOUT = 200
diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py
index a280e4833cfd..3549c2f7c3c8 100644
--- a/homeassistant/components/enphase_envoy/coordinator.py
+++ b/homeassistant/components/enphase_envoy/coordinator.py
@@ -4,6 +4,7 @@ import contextlib
import datetime
from datetime import timedelta
import logging
+import math
from typing import Any, override
from pyenphase import Envoy, EnvoyError, EnvoyTokenAuth
@@ -13,12 +14,13 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
+ CONF_MANUAL_TOKEN,
DOMAIN,
INVALID_AUTH_ERRORS,
OPERATIONAL_RETRY_TIMEOUT,
@@ -28,8 +30,8 @@ from .const import (
SCAN_INTERVAL = timedelta(seconds=60)
TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1)
-STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds()
-NOTIFICATION_ID = "enphase_envoy_notification"
+STALE_TOKEN_THRESHOLD = 30 # days
+TOKEN_REPAIR_ID = "enphase_envoy_token_expiry"
FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4)
MAC_VERIFICATION_DELAY = timedelta(seconds=34)
_LOGGER = logging.getLogger(__name__)
@@ -45,6 +47,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
envoy_firmware: str
config_entry: EnphaseConfigEntry
interface: EnvoyInterfaceInformation | None
+ token_lifetime: int # days of token life left
def __init__(
self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry
@@ -52,8 +55,9 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Initialize DataUpdateCoordinator for the envoy."""
self.envoy = envoy
entry_data = entry.data
- self.username = entry_data[CONF_USERNAME]
- self.password = entry_data[CONF_PASSWORD]
+ self.username = entry_data.get(CONF_USERNAME)
+ self.password = entry_data.get(CONF_PASSWORD)
+ self.manual_token = entry_data.get(CONF_MANUAL_TOKEN, False)
self._setup_complete = False
self._operational_timeout = False
self.envoy_firmware = ""
@@ -61,6 +65,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._cancel_token_refresh: CALLBACK_TYPE | None = None
self._cancel_firmware_refresh: CALLBACK_TYPE | None = None
self._cancel_mac_verification: CALLBACK_TYPE | None = None
+ self.token_lifetime = 0
super().__init__(
hass,
_LOGGER,
@@ -70,19 +75,69 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
always_update=False,
)
+ def _track_token_lifetime(self) -> bool:
+ """Update tokenlifetime and return if still fresh."""
+ assert isinstance(self.envoy.auth, EnvoyTokenAuth)
+ self.token_lifetime = max(
+ 0,
+ math.ceil(
+ (self.envoy.auth.expire_timestamp - dt_util.utcnow().timestamp())
+ / 86400
+ ),
+ )
+ return self.token_lifetime > STALE_TOKEN_THRESHOLD
+
@callback
def _async_refresh_token_if_needed(self, now: datetime.datetime) -> None:
"""Proactively refresh token if its stale in case cloud services goes down."""
assert isinstance(self.envoy.auth, EnvoyTokenAuth)
- expire_time = self.envoy.auth.expire_timestamp
- remain = expire_time - now.timestamp()
- fresh = remain > STALE_TOKEN_THRESHOLD
+ fresh = self._track_token_lifetime()
name = self.name
- _LOGGER.debug("%s: %s seconds remaining on token fresh=%s", name, remain, fresh)
+ _LOGGER.debug(
+ "%s: %s days remaining on token, fresh=%s, manual token mode=%s",
+ name,
+ self.token_lifetime,
+ fresh,
+ self.manual_token,
+ )
if not fresh:
- self.hass.async_create_background_task(
- self._async_try_refresh_token(), "{name} token refresh"
+ if not self.manual_token:
+ self.hass.async_create_background_task(
+ self._async_try_refresh_token(), f"{name} token refresh"
+ )
+ return
+
+ # User configured manual token entry, warn for upcoming expiry by issuing a repair
+ _LOGGER.debug(
+ "Create repair issue for %s token expiry in %s days",
+ self.name,
+ self.token_lifetime,
)
+ # Force issue rering each day until resolved by user
+ ir.async_delete_issue(
+ self.hass, DOMAIN, f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}"
+ )
+ ir.async_create_issue(
+ self.hass,
+ domain=DOMAIN,
+ issue_id=f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}",
+ is_fixable=False,
+ is_persistent=True,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="token_expiry",
+ translation_placeholders={
+ "token_lifetime": str(self.token_lifetime),
+ "name": self.name,
+ },
+ learn_more_url="https://www.home-assistant.io/integrations/enphase_envoy",
+ )
+ return
+ if not self.manual_token:
+ return
+ # remove any repair that warned user to refresh manual token
+ ir.async_delete_issue(
+ self.hass, DOMAIN, f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}"
+ )
async def _async_try_refresh_token(self) -> None:
"""Try to refresh token."""
@@ -256,6 +311,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# startup without hitting the Cloud API
# as long as the token is valid
_LOGGER.debug("%s: Updating token in config entry from auth", self.name)
+ self._track_token_lifetime()
self.hass.config_entries.async_update_entry(
self.config_entry,
data={
diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py
index 77d7c2a4dc97..7806ec781a23 100644
--- a/homeassistant/components/enphase_envoy/diagnostics.py
+++ b/homeassistant/components/enphase_envoy/diagnostics.py
@@ -5,11 +5,14 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any
from aiohttp import ClientResponse
-from attr import asdict
from pyenphase.envoy import Envoy
from pyenphase.exceptions import EnvoyError
-from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
+from homeassistant.components.diagnostics import (
+ async_redact_data,
+ device_entry_as_dict,
+ entity_entry_as_dict,
+)
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
@@ -119,10 +122,7 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context", None)
entity_dict = entity_entry_as_dict(entity)
entities.append({"entity": entity_dict, "state": state_dict})
- device_dict = asdict(device)
- device_dict.pop("_cache", None)
- # This can be removed when suggested_area is removed from DeviceEntry
- device_dict.pop("_suggested_area")
+ device_dict = device_entry_as_dict(device)
device_entities.append({"device": device_dict, "entities": entities})
# remove envoy serial
diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json
index 12ce059967b8..02ff75664d9f 100644
--- a/homeassistant/components/enphase_envoy/strings.json
+++ b/homeassistant/components/enphase_envoy/strings.json
@@ -16,10 +16,14 @@
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
+ "token": "[%key:component::enphase_envoy::config::step::user::data::token%]",
+ "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data::use_manual_token%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]",
+ "token": "[%key:component::enphase_envoy::config::step::user::data_description::token%]",
+ "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data_description::use_manual_token%]",
"username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]"
},
"description": "[%key:component::enphase_envoy::config::step::user::description%]"
@@ -28,11 +32,15 @@
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
+ "token": "[%key:component::enphase_envoy::config::step::user::data::token%]",
+ "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data::use_manual_token%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]",
"password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]",
+ "token": "[%key:component::enphase_envoy::config::step::user::data_description::token%]",
+ "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data_description::use_manual_token%]",
"username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]"
},
"description": "[%key:component::enphase_envoy::config::step::user::description%]"
@@ -41,11 +49,15 @@
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
+ "token": "Envoy access token",
+ "use_manual_token": "Enter the Envoy access token manually",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your Enphase Envoy gateway.",
"password": "Blank or Enphase Cloud password",
+ "token": "Go to the [Enphase login]({enphase_url}) to get a new token. Current token lifetime: {token_life} days.",
+ "use_manual_token": "If your Enphase Cloud account has multi-factor authentication enabled, check this option to add or update your token manually.",
"username": "Installer or Enphase Cloud username"
},
"description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models, enter username `installer` without a password."
@@ -723,10 +735,28 @@
"envoy_error": {
"message": "Error communicating with Envoy API on {host}: {args}"
},
+ "envoy_token_lifetime_service_envoy_not_found": {
+ "message": "No Envoy found by token lifetime action for device ID {device_id}."
+ },
+ "envoy_token_lifetime_service_no_device_id": {
+ "message": "No Envoy found by token lifetime action. Configure an Envoy or specify a `device_id` if more than 1 Envoy is configured."
+ },
+ "no_token_auth": {
+ "message": "{service}: Envoy token authorization is only used with firmware version 7.0 and later."
+ },
+ "not_initialized": {
+ "message": "{service}: Enphase Envoy is not yet initialized"
+ },
"unexpected_device": {
"message": "Unexpected Envoy serial number found at {host}; expected {expected_serial}, found {actual_serial}"
}
},
+ "issues": {
+ "token_expiry": {
+ "description": "The {name} access token expires in {token_lifetime} days and is configured for manual token entry. Make sure to enter a new token before the final expiry day. To update the token, go to the Home Assistant Enphase Envoy integration page and use the reconfigure menu option.",
+ "title": "{name} access token expires in {token_lifetime} days."
+ }
+ },
"options": {
"step": {
"init": {
diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py
index 2572c735fa74..a43e350a45fc 100644
--- a/homeassistant/components/environment_canada/__init__.py
+++ b/homeassistant/components/environment_canada/__init__.py
@@ -12,12 +12,16 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
+ CONF_RADAR_DURATION,
+ CONF_RADAR_FPS,
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
+ DEFAULT_RADAR_DURATION,
+ DEFAULT_RADAR_FPS,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
@@ -75,6 +79,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)),
radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)),
+ loop_minutes=int(options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION)),
+ fps=int(options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS)),
)
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py
index c5aadd3bd3e9..3a56dd576f6b 100644
--- a/homeassistant/components/environment_canada/config_flow.py
+++ b/homeassistant/components/environment_canada/config_flow.py
@@ -30,6 +30,8 @@ from homeassistant.helpers.selector import (
)
from .const import (
+ CONF_RADAR_DURATION,
+ CONF_RADAR_FPS,
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
@@ -37,6 +39,8 @@ from .const import (
CONF_RADAR_TIMESTAMP,
CONF_STATION,
CONF_TITLE,
+ DEFAULT_RADAR_DURATION,
+ DEFAULT_RADAR_FPS,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
@@ -210,6 +214,22 @@ class OptionsFlowHandler(OptionsFlowWithReload):
min=10, max=2000, step=10, unit_of_measurement="km"
)
),
+ vol.Required(
+ CONF_RADAR_DURATION,
+ default=options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION),
+ ): NumberSelector(
+ NumberSelectorConfig(
+ min=0, max=180, step=5, unit_of_measurement="min"
+ )
+ ),
+ vol.Required(
+ CONF_RADAR_FPS,
+ default=options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS),
+ ): NumberSelector(
+ NumberSelectorConfig(
+ min=1, max=30, step=1, unit_of_measurement="fps"
+ )
+ ),
}
)
diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py
index 39a9f3a949a7..52cf4b6564e4 100644
--- a/homeassistant/components/environment_canada/const.py
+++ b/homeassistant/components/environment_canada/const.py
@@ -12,6 +12,8 @@ CONF_RADAR_LEGEND = "radar_legend"
CONF_RADAR_TIMESTAMP = "radar_timestamp"
CONF_RADAR_OPACITY = "radar_opacity"
CONF_RADAR_RADIUS = "radar_radius"
+CONF_RADAR_DURATION = "radar_duration"
+CONF_RADAR_FPS = "radar_fps"
RADAR_LAYERS = ["rain", "snow", "precip_type"]
@@ -22,3 +24,6 @@ DEFAULT_RADAR_LEGEND = False
DEFAULT_RADAR_TIMESTAMP = True
DEFAULT_RADAR_OPACITY = 65
DEFAULT_RADAR_RADIUS = 200
+# 0 means use the full range of images Environment Canada reports as available.
+DEFAULT_RADAR_DURATION = 0
+DEFAULT_RADAR_FPS = 5
diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json
index 93acc0ecc558..18491e942b25 100644
--- a/homeassistant/components/environment_canada/strings.json
+++ b/homeassistant/components/environment_canada/strings.json
@@ -121,6 +121,8 @@
"step": {
"init": {
"data": {
+ "radar_duration": "Loop duration",
+ "radar_fps": "Loop frame rate",
"radar_layer": "Radar type",
"radar_legend": "Show legend",
"radar_opacity": "Radar opacity",
@@ -128,6 +130,8 @@
"radar_timestamp": "Show timestamp"
},
"data_description": {
+ "radar_duration": "How far back the radar animation goes, in minutes (0 for the full available history)",
+ "radar_fps": "Frame rate of the radar animation",
"radar_opacity": "Opacity of the radar layer overlay (0-100)",
"radar_radius": "Radius of the radar map in kilometres"
},
diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py
index 5d329b61974f..b7c2eb8352e8 100644
--- a/homeassistant/components/esphome/__init__.py
+++ b/homeassistant/components/esphome/__init__.py
@@ -2,7 +2,7 @@
import logging
-from aioesphomeapi import APIClient, APIConnectionError
+from aioesphomeapi import APIConnectionError
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner
@@ -11,13 +11,7 @@ from homeassistant.components.usb import (
USBDevice,
async_register_serial_port_scanner,
)
-from homeassistant.const import (
- CONF_HOST,
- CONF_PASSWORD,
- CONF_PORT,
- EVENT_HOMEASSISTANT_STOP,
- __version__ as ha_version,
-)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import async_delete_issue
@@ -29,15 +23,18 @@ from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData
from .encryption_key_storage import async_get_encryption_key_storage
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
-from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
+from .manager import (
+ DEVICE_CONFLICT_ISSUE_FORMAT,
+ ESPHomeManager,
+ async_create_api_client,
+ cleanup_instance,
+)
from .websocket_api import async_setup as async_setup_websocket_api
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-CLIENT_INFO = f"Home Assistant {ha_version}"
-
@callback
def _async_scan_serial_ports(
@@ -90,20 +87,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
"""Set up the esphome component."""
host: str = entry.data[CONF_HOST]
- port: int = entry.data[CONF_PORT]
password: str | None = entry.data[CONF_PASSWORD]
- noise_psk: str | None = entry.data.get(CONF_NOISE_PSK)
zeroconf_instance = await zeroconf.async_get_instance(hass)
- cli = APIClient(
- host,
- port,
- password,
- client_info=CLIENT_INFO,
- zeroconf_instance=zeroconf_instance,
- noise_psk=noise_psk,
- timezone=hass.config.time_zone,
+ cli = async_create_api_client(
+ hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK)
)
domain_data = DomainData.get(hass)
@@ -159,21 +148,10 @@ async def _async_clear_dynamic_encryption_key(
if await storage.async_get_key(entry.unique_id) is None:
return
- host: str = entry.data[CONF_HOST]
- port: int = entry.data[CONF_PORT]
- password: str | None = entry.data[CONF_PASSWORD]
- noise_psk: str | None = entry.data.get(CONF_NOISE_PSK)
-
zeroconf_instance = await zeroconf.async_get_instance(hass)
- cli = APIClient(
- host,
- port,
- password,
- client_info=CLIENT_INFO,
- zeroconf_instance=zeroconf_instance,
- noise_psk=noise_psk,
- timezone=hass.config.time_zone,
+ cli = async_create_api_client(
+ hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK)
)
try:
diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py
index ce7c3037c9e6..0182cacf9cfb 100644
--- a/homeassistant/components/esphome/assist_satellite.py
+++ b/homeassistant/components/esphome/assist_satellite.py
@@ -4,14 +4,12 @@ import asyncio
from collections.abc import AsyncIterable
from functools import partial
import hashlib
-import io
from itertools import chain
import json
import logging
from pathlib import Path
import socket
from typing import Any, cast, override
-import wave
from aioesphomeapi import (
MediaPlayerFormatPurpose,
@@ -53,6 +51,7 @@ from .entity import EsphomeAssistEntity, convert_api_error_ha_error
from .entry_data import ESPHomeConfigEntry
from .enum_mapper import EsphomeEnumMapper
from .ffmpeg_proxy import async_create_proxy_url
+from .wav_parser import stream_wav
PARALLEL_UPDATES = 0
@@ -726,36 +725,41 @@ class EsphomeAssistSatellite(
)
return
- data = b"".join([chunk async for chunk in tts_result.async_stream_result()])
+ seconds_in_chunk = samples_per_chunk / sample_rate
+ start_time: float | None = None
+ audio_duration_sent = 0.0
- with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file:
- if (
- (wav_file.getframerate() != sample_rate)
- or (wav_file.getsampwidth() != sample_width)
- or (wav_file.getnchannels() != sample_channels)
- ):
- _LOGGER.error("Can only stream 16Khz 16-bit mono WAV")
- return
+ async for chunk, is_last in stream_wav(
+ tts_result.async_stream_result(),
+ expected_format="pcm",
+ expected_channels=sample_channels,
+ expected_width=sample_width,
+ expected_sample_rate=sample_rate,
+ samples_per_chunk=samples_per_chunk,
+ ):
+ if not self._is_running:
+ break # type: ignore[unreachable]
- _LOGGER.debug("Streaming %s audio samples", wav_file.getnframes())
+ if start_time is None:
+ start_time = asyncio.get_running_loop().time()
- while self._is_running:
- chunk = wav_file.readframes(samples_per_chunk)
- if not chunk:
- break
+ self._send_tts_audio(chunk)
- if self._udp_server is not None:
- self._udp_server.send_audio_bytes(chunk)
- else:
- self.cli.send_voice_assistant_audio(chunk)
+ audio_duration_sent += seconds_in_chunk
- # Wait for 90% of the duration of the audio that was
- # sent for it to be played. This will overrun the
- # device's buffer for very long audio, so using a media
- # player is preferred.
- samples_in_chunk = len(chunk) // (sample_width * sample_channels)
- seconds_in_chunk = samples_in_chunk / sample_rate
- await asyncio.sleep(seconds_in_chunk * 0.9)
+ if is_last:
+ break
+
+ # The ring buffer in the remote device is fixed at 512ms.
+ # We want to keep it at around 384ms (75% full) to prevent
+ # the buffer from overflowing or underflowing.
+ assert start_time is not None
+ elapsed = asyncio.get_running_loop().time() - start_time
+ if (wait_time := (audio_duration_sent - 0.384) - elapsed) > 0:
+ await asyncio.sleep(wait_time)
+
+ except ValueError as err:
+ _LOGGER.error("Error streaming WAV: %s", err)
except asyncio.CancelledError:
return # Don't trigger state change
finally:
@@ -767,6 +771,13 @@ class EsphomeAssistSatellite(
self.tts_response_finished()
self._entry_data.async_set_assist_pipeline_state(False)
+ def _send_tts_audio(self, payload: bytes) -> None:
+ """Send TTS audio via API or UDP."""
+ if self._udp_server is not None:
+ self._udp_server.send_audio_bytes(payload)
+ else:
+ self.cli.send_voice_assistant_audio(payload)
+
async def _wrap_audio_stream(self) -> AsyncIterable[bytes]:
"""Yield audio chunks from the queue until None."""
while True:
diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py
index 71b99ced5fea..1d3488d3cd00 100644
--- a/homeassistant/components/esphome/config_flow.py
+++ b/homeassistant/components/esphome/config_flow.py
@@ -74,7 +74,11 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
_LOGGER = logging.getLogger(__name__)
-ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
+# A deliberately wrong key (base64 of thirty two ASCII zero characters, not
+# zero bytes) used only to elicit the server hello so the device name can be
+# read. Not to be confused with aioesphomeapi.ZERO_NOISE_PSK, the well known
+# all zeros provisioning key.
+PROBE_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
DEFAULT_NAME = "ESPHome"
_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector(
@@ -271,7 +275,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# to get the device name which will allow us to populate
# the device name and hopefully get the encryption key
# from the dashboard.
- self._noise_psk = ZERO_NOISE_PSK
+ self._noise_psk = PROBE_NOISE_PSK
response = await self.fetch_device_info()
self._noise_psk = None
diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py
index b10995ac27cc..508065b091c8 100644
--- a/homeassistant/components/esphome/const.py
+++ b/homeassistant/components/esphome/const.py
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final
from awesomeversion import AwesomeVersion
from homeassistant.components.bluetooth import BluetoothScanningMode
+from homeassistant.const import __version__ as ha_version
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
@@ -12,6 +13,8 @@ if TYPE_CHECKING:
DOMAIN = "esphome"
+CLIENT_INFO = f"Home Assistant {ha_version}"
+
ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN)
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py
index dfe4741a0d6e..ec4e2ad9cc43 100644
--- a/homeassistant/components/esphome/coordinator.py
+++ b/homeassistant/components/esphome/coordinator.py
@@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0")
+MIN_VERSION_SUPPORTS_BUILD_QUEUE = AwesomeVersion("2026.6.0")
REFRESH_INTERVAL = timedelta(minutes=5)
@@ -34,6 +35,7 @@ class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevi
self.url = url
self.api = ESPHomeDashboardAPI(url, async_get_clientsession(hass))
self.supports_update: bool | None = None
+ self.supports_build_queue = False
@override
async def _async_update_data(self) -> dict[str, ConfiguredDevice]:
@@ -41,13 +43,14 @@ class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevi
devices = await self.api.get_devices()
configured_devices = devices["configured"]
- if (
- self.supports_update is None
- and configured_devices
- and (current_version := configured_devices[0].get("current_version"))
+ if configured_devices and (
+ current_version := configured_devices[0].get("current_version")
):
- self.supports_update = (
- AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE
- )
+ version = AwesomeVersion(current_version)
+ if self.supports_update is None:
+ self.supports_update = version > MIN_VERSION_SUPPORTS_UPDATE
+ # The dashboard has its own build queue since 2026.6.0
+ # and can accept multiple compile requests at once
+ self.supports_build_queue = version >= MIN_VERSION_SUPPORTS_BUILD_QUEUE
return {dev["name"]: dev for dev in configured_devices}
diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py
index eb6f2772d2cb..2d5ae2161d13 100644
--- a/homeassistant/components/esphome/entity.py
+++ b/homeassistant/components/esphome/entity.py
@@ -12,6 +12,7 @@ from aioesphomeapi import (
EntityCategory as EsphomeEntityCategory,
EntityInfo,
EntityState,
+ build_device_unique_id,
)
import voluptuous as vol
@@ -31,12 +32,7 @@ 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 (
- DeviceEntityKey,
- ESPHomeConfigEntry,
- RuntimeEntryData,
- build_device_unique_id,
-)
+from .entry_data import DeviceEntityKey, ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
index 7dd004c2af83..8ee0527661a3 100644
--- a/homeassistant/components/esphome/entry_data.py
+++ b/homeassistant/components/esphome/entry_data.py
@@ -45,7 +45,7 @@ from aioesphomeapi import (
UserService,
ValveInfo,
WaterHeaterInfo,
- build_unique_id,
+ build_device_unique_id,
)
from aioesphomeapi.model import ButtonInfo
from bleak_esphome.backend.device import ESPHomeBluetoothDevice
@@ -59,7 +59,7 @@ from homeassistant.helpers import discovery_flow, entity_registry as er
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.storage import Store
-from .const import DOMAIN
+from .const import CONF_NOISE_PSK, DOMAIN
from .dashboard import async_get_dashboard
type ESPHomeConfigEntry = ConfigEntry[RuntimeEntryData]
@@ -103,22 +103,6 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
}
-def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str:
- """Build unique ID for entity, appending @device_id if it belongs to a sub-device.
-
- This wrapper around build_unique_id ensures that entities belonging to sub-devices
- have their device_id appended to the unique_id to handle proper migration when
- entities move between devices.
- """
- base_unique_id = build_unique_id(mac, entity_info)
-
- # If entity belongs to a sub-device, append @device_id
- if entity_info.device_id:
- return f"{base_unique_id}@{entity_info.device_id}"
-
- return base_unique_id
-
-
class StoreData(TypedDict, total=False):
"""ESPHome storage data."""
@@ -310,11 +294,31 @@ class RuntimeEntryData:
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
list
)
+ ent_reg = er.async_get(hass)
+ registry_get_entity = ent_reg.async_get_entity_id
for info in infos:
info_type = type(info)
if platform := info_types_to_platform.get(info_type):
needed_platforms.add(platform)
infos_by_type[info_type].append(info)
+ # Migrate legacy unique ids to the version 3 format that fixes
+ # UTF-8 collisions. Skip when a version 3 id already exists so a
+ # downgrade then upgrade keeps the original entity. When two
+ # legacy ids collided (the bug this fixes) only one registry
+ # entry exists for it, so the first iterated info claims it and
+ # the rest get fresh version 3 ids.
+ old_unique_id = build_device_unique_id(mac, info, version=1)
+ new_unique_id = build_device_unique_id(mac, info, version=3)
+ if (
+ old_unique_id != new_unique_id
+ and (
+ old_entry := registry_get_entity(
+ platform, DOMAIN, old_unique_id
+ )
+ )
+ and not registry_get_entity(platform, DOMAIN, new_unique_id)
+ ):
+ ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id)
else:
_LOGGER.warning(
"Entity type %s is not supported in this version of Home Assistant",
@@ -513,6 +517,8 @@ class RuntimeEntryData:
) -> None:
"""Create a zwave_js config flow for a Z-Wave JS Proxy device."""
assert self.client.connected_address is not None
+ entry = hass.config_entries.async_get_entry(self.entry_id)
+ noise_psk = entry.data.get(CONF_NOISE_PSK) if entry else None
discovery_flow.async_create_flow(
hass,
"zwave_js",
@@ -522,7 +528,7 @@ class RuntimeEntryData:
zwave_home_id=zwave_home_id,
ip_address=self.client.connected_address,
port=self.client.port,
- noise_psk=self.client.noise_psk,
+ noise_psk=noise_psk or None,
),
discovery_key=discovery_flow.DiscoveryKey(
domain=DOMAIN,
diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py
index 3eeaed77be5e..90e0760da46b 100644
--- a/homeassistant/components/esphome/manager.py
+++ b/homeassistant/components/esphome/manager.py
@@ -9,6 +9,7 @@ import struct
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from aioesphomeapi import (
+ ZERO_NOISE_PSK,
APIClient,
APIConnectionError,
APIVersion,
@@ -34,7 +35,10 @@ import voluptuous as vol
from homeassistant.components import bluetooth, tag, zeroconf
from homeassistant.const import (
ATTR_DEVICE_ID,
+ CONF_HOST,
CONF_MODE,
+ CONF_PASSWORD,
+ CONF_PORT,
EVENT_HOMEASSISTANT_CLOSE,
EVENT_LOGGING_CHANGED,
Platform,
@@ -77,6 +81,7 @@ from homeassistant.util.json import json_loads_object
from .bluetooth import async_connect_scanner
from .const import (
+ CLIENT_INFO,
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_MAC_ADDRESS,
CONF_DEVICE_NAME,
@@ -101,6 +106,26 @@ DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
+@callback
+def async_create_api_client(
+ hass: HomeAssistant,
+ entry: ESPHomeConfigEntry,
+ zeroconf_instance: zeroconf.HaZeroconf,
+ *,
+ noise_psk: str | None,
+) -> APIClient:
+ """Create an APIClient for a config entry."""
+ return APIClient(
+ entry.data[CONF_HOST],
+ entry.data[CONF_PORT],
+ entry.data[CONF_PASSWORD],
+ client_info=CLIENT_INFO,
+ zeroconf_instance=zeroconf_instance,
+ noise_psk=noise_psk,
+ timezone=hass.config.time_zone,
+ )
+
+
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001
@@ -812,6 +837,51 @@ class ESPHomeManager:
if self.reconnect_logic:
await self.reconnect_logic.stop()
+ async def _async_provision_key_over_noise(self, new_key: bytes) -> bool:
+ """Send the encryption key over a short lived zero PSK Noise connection.
+
+ The well known all zeros PSK still runs a fresh ephemeral X25519
+ exchange, so the key cannot be read by a passive listener on the
+ network. This protects against sniffing only; it does not
+ authenticate either side against an active man in the middle.
+
+ Returns True if the device accepted the key. On failure the caller
+ simply returns; provisioning runs again on the next connect cycle.
+ """
+ unique_id = self.entry.unique_id
+ cli = async_create_api_client(
+ self.hass, self.entry, self.zeroconf_instance, noise_psk=ZERO_NOISE_PSK
+ )
+ device_name = self.entry.data.get(CONF_DEVICE_NAME, self.host)
+ try:
+ await cli.connect()
+ if await cli.noise_encryption_set_key(new_key):
+ return True
+ _LOGGER.error(
+ "Device %s (%s) rejected the encryption key",
+ device_name,
+ unique_id,
+ )
+ except InvalidEncryptionKeyAPIError:
+ _LOGGER.error(
+ "Device %s (%s) rejected the zero PSK handshake; it appears "
+ "to already have an encryption key set",
+ device_name,
+ unique_id,
+ )
+ except APIConnectionError as ex:
+ # Whatever went wrong, we never downgrade to a plaintext push;
+ # provisioning simply runs again on the next connect cycle
+ _LOGGER.error(
+ "Error provisioning encryption key for device %s (%s): %s",
+ device_name,
+ unique_id,
+ ex,
+ )
+ finally:
+ await cli.disconnect(force=True)
+ return False
+
async def _handle_dynamic_encryption_key(
self, device_info: EsphomeDeviceInfo
) -> None:
@@ -853,18 +923,24 @@ class ESPHomeManager:
new_key = base64.b64encode(secrets.token_bytes(32))
new_key_str = new_key.decode()
- try:
- # Store the key on the device using the existing connection
- result = await self.cli.noise_encryption_set_key(new_key)
- except APIConnectionError as ex:
- _LOGGER.error(
- "Connection error while storing encryption key for device %s (%s): %s",
- self.entry.data.get(CONF_DEVICE_NAME, self.host),
- self.entry.unique_id,
- ex,
- )
- return
+ if device_info.api_encryption_provisionable:
+ # New firmware: send the key over an encrypted zero PSK Noise
+ # connection so it cannot be sniffed off the network
+ if not await self._async_provision_key_over_noise(new_key):
+ return
else:
+ # Old firmware only accepts the key over the existing plaintext
+ # connection. Deprecated; will be removed after the usual window.
+ try:
+ result = await self.cli.noise_encryption_set_key(new_key)
+ except APIConnectionError as ex:
+ _LOGGER.error(
+ "Connection error while storing encryption key for device %s (%s): %s",
+ self.entry.data.get(CONF_DEVICE_NAME, self.host),
+ self.entry.unique_id,
+ ex,
+ )
+ return
if not result:
_LOGGER.error(
"Failed to set dynamic encryption key on device %s (%s)",
@@ -917,8 +993,8 @@ class ESPHomeManager:
# Remove this after 2026.4
if not (
stale_entry_entity_id := ent_reg.async_get_entity_id(
- DOMAIN,
Platform.BINARY_SENSOR,
+ DOMAIN,
f"{self.entry_data.device_info.mac_address}-assist_in_progress",
)
):
@@ -1417,13 +1493,14 @@ async def async_replace_device(
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)
+ # The mac is the leading segment of the unique id in every format,
+ # so swap the prefix without parsing the rest.
+ if entity.unique_id.startswith(old_upper_mac):
+ new_unique_id = upper_mac + entity.unique_id[len(old_upper_mac) :]
+ if new_unique_id != entity.unique_id:
+ 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)
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index 6328164acb87..7eb5b9744daf 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
- "aioesphomeapi==45.5.2",
+ "aioesphomeapi==45.6.0",
"esphome-dashboard-api==1.3.0",
- "bleak-esphome==3.9.4"
+ "bleak-esphome==3.9.7"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py
index eff6c6ab4b73..2b6f49272c04 100644
--- a/homeassistant/components/esphome/update.py
+++ b/homeassistant/components/esphome/update.py
@@ -233,21 +233,27 @@ class ESPHomeDashboardUpdateEntity(
# Ensure only one OTA per device at a time
async with self._install_lock:
- # Ensure only one compile at a time for ALL devices
- async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()):
- coordinator = self.coordinator
- api = coordinator.api
- device = coordinator.data.get(self._device_info.name)
- assert device is not None
- configuration = device["configuration"]
- if not await api.compile(configuration):
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="error_compiling",
- translation_placeholders={
- "configuration": configuration,
- },
- )
+ coordinator = self.coordinator
+ api = coordinator.api
+ device = coordinator.data.get(self._device_info.name)
+ assert device is not None
+ configuration = device["configuration"]
+ if coordinator.supports_build_queue:
+ # The dashboard has its own build queue
+ # and can handle concurrent compile requests
+ compiled = await api.compile(configuration)
+ else:
+ # Ensure only one compile at a time for ALL devices
+ async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()):
+ compiled = await api.compile(configuration)
+ if not compiled:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="error_compiling",
+ translation_placeholders={
+ "configuration": configuration,
+ },
+ )
# If the device uses deep sleep, there's a small chance it goes
# to sleep right after the dashboard connects but before the OTA
diff --git a/homeassistant/components/esphome/wav_parser.py b/homeassistant/components/esphome/wav_parser.py
new file mode 100644
index 000000000000..892c89287f9f
--- /dev/null
+++ b/homeassistant/components/esphome/wav_parser.py
@@ -0,0 +1,146 @@
+"""Helper to parse and stream WAV files."""
+
+from collections.abc import AsyncIterable, AsyncIterator
+import struct
+
+
+class WAVHeaderParser:
+ """Helper to parse WAV headers from a byte buffer."""
+
+ def __init__(
+ self,
+ expected_channels: int,
+ expected_width: int,
+ expected_sample_rate: int,
+ ) -> None:
+ """Initialize the WAV header parser."""
+ self.expected_channels = expected_channels
+ self.expected_width = expected_width
+ self.expected_sample_rate = expected_sample_rate
+ self.riff_checked = False
+ self.fmt_validated = False
+ self.data_bytes_remaining = 0
+ self.found_data = False
+
+ def parse(self, bytes_buffer: bytearray) -> bool:
+ """Parse headers from the buffer. Returns True if headers are fully parsed."""
+ while True:
+ if not self.riff_checked:
+ if len(bytes_buffer) < 12:
+ return False
+ riff, _, wave_fmt = struct.unpack("<4sI4s", bytes_buffer[:12])
+ if riff != b"RIFF" or wave_fmt != b"WAVE":
+ raise ValueError("Invalid WAV format: missing RIFF/WAVE header")
+ self.riff_checked = True
+ del bytes_buffer[:12]
+
+ if len(bytes_buffer) < 8:
+ return False
+
+ chunk_id, chunk_size = struct.unpack("<4sI", bytes_buffer[:8])
+
+ if chunk_id == b"fmt ":
+ if len(bytes_buffer) < 8 + chunk_size + (chunk_size & 1):
+ return False
+
+ if chunk_size < 16:
+ raise ValueError(f"WAV fmt chunk too small: {chunk_size} bytes")
+
+ (
+ audio_format,
+ num_channels,
+ chunk_sample_rate,
+ _,
+ _,
+ bits_per_sample,
+ ) = struct.unpack(" AsyncIterator[tuple[bytes, bool]]:
+ """Parse a WAV stream, validate its header, and yield chunks of audio data."""
+ if expected_format != "pcm":
+ raise ValueError(f"Unsupported expected format: {expected_format}")
+
+ parser = WAVHeaderParser(expected_channels, expected_width, expected_sample_rate)
+ bytes_buffer = bytearray()
+ bytes_per_chunk_payload = samples_per_chunk * expected_width * expected_channels
+ pending_chunk: bytes | None = None
+
+ async for chunk in stream:
+ bytes_buffer.extend(chunk)
+
+ if not parser.found_data and not parser.parse(bytes_buffer):
+ continue
+
+ while (
+ parser.data_bytes_remaining >= bytes_per_chunk_payload
+ and len(bytes_buffer) >= bytes_per_chunk_payload
+ ):
+ payload = bytes(bytes_buffer[:bytes_per_chunk_payload])
+ del bytes_buffer[:bytes_per_chunk_payload]
+ parser.data_bytes_remaining -= bytes_per_chunk_payload
+
+ if pending_chunk is not None:
+ yield pending_chunk, False
+
+ pending_chunk = payload
+
+ if parser.data_bytes_remaining == 0:
+ yield pending_chunk, True
+ pending_chunk = None
+ return
+
+ if not parser.found_data:
+ raise ValueError("Invalid WAV format: incomplete or missing data chunk")
+
+ remaining_bytes_to_read = min(parser.data_bytes_remaining, len(bytes_buffer))
+ if remaining_bytes_to_read > 0:
+ remaining = bytes(bytes_buffer[:remaining_bytes_to_read])
+ if pending_chunk is not None:
+ yield pending_chunk, False
+ pending_chunk = remaining
+
+ if pending_chunk is not None:
+ yield pending_chunk, True
diff --git a/homeassistant/components/etherscan/__init__.py b/homeassistant/components/etherscan/__init__.py
index 0e983bd6bead..b7665204e229 100644
--- a/homeassistant/components/etherscan/__init__.py
+++ b/homeassistant/components/etherscan/__init__.py
@@ -1 +1 @@
-"""The etherscan component."""
+"""The Etherscan integration."""
diff --git a/homeassistant/components/evohome/button.py b/homeassistant/components/evohome/button.py
index 9e27cb74de73..d6453be98fdd 100644
--- a/homeassistant/components/evohome/button.py
+++ b/homeassistant/components/evohome/button.py
@@ -30,14 +30,14 @@ async def async_setup_platform(
coordinator = hass.data[EVOHOME_DATA].coordinator
tcs = hass.data[EVOHOME_DATA].tcs
- entities: list[EvoResetButtonBase] = [EvoResetSystemButton(coordinator, tcs)]
+ entities: list[EvoResetButtonBase] = [EvoSystemResetButton(coordinator, tcs)]
entities.extend(
- [EvoResetZoneButton(coordinator, z) for z in tcs.zones if is_valid_zone(z)]
+ EvoZoneResetButton(coordinator, z) for z in tcs.zones if is_valid_zone(z)
)
if tcs.hotwater:
- entities.append(EvoResetDhwButton(coordinator, tcs.hotwater))
+ entities.append(EvoDhwResetButton(coordinator, tcs.hotwater))
async_add_entities(entities)
@@ -45,6 +45,7 @@ async def async_setup_platform(
class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEntity):
"""Base for Evohome's Button entities."""
+ # for _attr_device_class, ButtonDeviceClass.RESET is not available
_attr_entity_category = EntityCategory.CONFIG
_evo_device: evo.ControlSystem | evo.HotWater | evo.Zone
@@ -56,15 +57,18 @@ class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEnti
) -> None:
"""Initialize an Evohome reset button entity."""
super().__init__(coordinator, context=evo_device.id)
+
self._evo_device = evo_device
+ self._attr_unique_id = f"{evo_device.id}_reset"
+
@override
async def async_press(self) -> None:
"""Reset the Evohome entity to its base operating mode."""
await self.coordinator.call_client_api(self._evo_device.reset())
-class EvoResetSystemButton(EvoResetButtonBase):
+class EvoSystemResetButton(EvoResetButtonBase):
"""Button entity for system reset."""
_evo_device: evo.ControlSystem
@@ -77,28 +81,26 @@ class EvoResetSystemButton(EvoResetButtonBase):
"""Initialize the system reset button."""
super().__init__(coordinator, evo_device)
- self._attr_unique_id = f"{evo_device.id}_reset"
- self._attr_name = f"Reset {evo_device.location.name}"
+ @property
+ @override
+ def name(self) -> str:
+ """Return the entity name (follows location renames)."""
+ return f"Reset {self._evo_device.location.name}"
-class EvoResetDhwButton(EvoResetButtonBase):
+class EvoDhwResetButton(EvoResetButtonBase):
"""Button entity for DHW override reset."""
_evo_device: evo.HotWater
- def __init__(
- self,
- coordinator: EvoDataUpdateCoordinator,
- evo_device: evo.HotWater,
- ) -> None:
- """Initialize the DHW reset button."""
- super().__init__(coordinator, evo_device)
-
- self._attr_unique_id = f"{evo_device.id}_reset"
- self._attr_name = f"Reset {evo_device.name}"
+ @property
+ @override
+ def name(self) -> str:
+ """Return the entity name (follows location renames)."""
+ return f"Reset {self._evo_device.location.name} DHW"
-class EvoResetZoneButton(EvoResetButtonBase):
+class EvoZoneResetButton(EvoResetButtonBase):
"""Button entity for zone override reset."""
_evo_device: evo.Zone
@@ -110,10 +112,11 @@ class EvoResetZoneButton(EvoResetButtonBase):
) -> None:
"""Initialize the zone reset button."""
super().__init__(coordinator, evo_device)
+
self._attr_unique_id = f"{unique_zone_id(evo_device)}_reset"
@property
@override
def name(self) -> str:
- """Return the name, dynamically following any zone rename."""
+ """Return the entity name (follows zone renames)."""
return f"Reset {self._evo_device.name}"
diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py
index 032eef86b9ff..534e64ae5a7a 100644
--- a/homeassistant/components/evohome/climate.py
+++ b/homeassistant/components/evohome/climate.py
@@ -6,10 +6,14 @@ from typing import Any, override
import evohomeasync2 as evo
from evohomeasync2.const import (
+ SZ_DURATION,
+ SZ_MODE,
+ SZ_PERIOD,
SZ_SETPOINT_STATUS,
SZ_SYSTEM_MODE,
SZ_SYSTEM_MODE_STATUS,
SZ_TEMPERATURE_STATUS,
+ SZ_UNTIL,
SystemMode as EvoSystemMode,
ZoneMode as EvoZoneMode,
)
@@ -23,12 +27,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.const import (
- ATTR_MODE,
- ATTR_TEMPERATURE,
- PRECISION_TENTHS,
- UnitOfTemperature,
-)
+from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -36,14 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
-from .const import (
- ATTR_DURATION,
- ATTR_PERIOD,
- DOMAIN,
- EVOHOME_DATA,
- RESET_BREAKS_IN_HA_VERSION,
- EvoService,
-)
+from .const import DOMAIN, EVOHOME_DATA, RESET_BREAKS_IN_HA_VERSION, EvoService
from .coordinator import EvoDataUpdateCoordinator
from .entity import EvoChild, EvoEntity, is_valid_zone, unique_zone_id
from .helpers import async_create_deprecation_issue_once
@@ -270,7 +262,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
temperature = kwargs[ATTR_TEMPERATURE]
- if (until := kwargs.get("until")) is None:
+ if (until := kwargs.get(SZ_UNTIL)) is None:
if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE:
until = self._evo_device.until
if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE:
@@ -395,14 +387,14 @@ class EvoController(EvoClimateEntity):
await self.coordinator.call_client_api(self._evo_device.reset())
return
- mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE
+ mode = data[SZ_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE
- if ATTR_PERIOD in data:
+ if SZ_PERIOD in data:
until = dt_util.start_of_local_day()
- until += data[ATTR_PERIOD]
+ until += data[SZ_PERIOD]
- elif ATTR_DURATION in data:
- until = dt_util.now() + data[ATTR_DURATION]
+ elif SZ_DURATION in data:
+ until = dt_util.now() + data[SZ_DURATION]
else:
until = None
diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py
index 06baf09cfc4c..3023b9081455 100644
--- a/homeassistant/components/evohome/const.py
+++ b/homeassistant/components/evohome/const.py
@@ -20,10 +20,6 @@ CONF_LOCATION_IDX: Final = "location_idx"
SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60)
-ATTR_DURATION: Final = "duration" # number of minutes, <24h
-ATTR_PERIOD: Final = "period" # number of days
-ATTR_SETPOINT: Final = "setpoint"
-
# Support for the refresh_system service is being deprecated
REFRESH_BREAKS_IN_HA_VERSION: Final = "2027.1.0"
# Support for the reset service calls/presets is being deprecated
diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py
index dcfa47602df3..3435bf0e19b2 100644
--- a/homeassistant/components/evohome/entity.py
+++ b/homeassistant/components/evohome/entity.py
@@ -8,6 +8,9 @@ from typing import Any, override
import evohomeasync2 as evo
from evohomeasync2.const import (
+ SZ_SINCE,
+ SZ_TIME_UNTIL,
+ SZ_UNTIL,
ZoneModelType as EvoZoneModelType,
ZoneType as EvoZoneType,
)
@@ -28,7 +31,7 @@ def _recurse_and_revert(val: Any, _key: str | None = None) -> Any:
return {k: _recurse_and_revert(v, k) for k, v in val.items()}
if isinstance(val, (list, tuple)):
return type(val)(_recurse_and_revert(v) for v in val)
- if isinstance(val, datetime) and _key in ("since", "time_until", "until"):
+ if isinstance(val, datetime) and _key in (SZ_SINCE, SZ_TIME_UNTIL, SZ_UNTIL):
return val.isoformat()
if isinstance(val, StrEnum):
return "".join(word.capitalize() for word in val.value.split("_"))
diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py
index a95d528b7d53..1c4e1344bd18 100644
--- a/homeassistant/components/evohome/services.py
+++ b/homeassistant/components/evohome/services.py
@@ -8,7 +8,10 @@ from evohomeasync2 import ControlSystem
from evohomeasync2.const import (
SZ_CAN_BE_TEMPORARY,
SZ_DURATION,
+ SZ_MODE,
SZ_PERIOD,
+ SZ_SETPOINT,
+ SZ_STATE,
SZ_SYSTEM_MODE,
SZ_TIMING_MODE,
)
@@ -16,7 +19,7 @@ import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
-from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE
+from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
@@ -28,9 +31,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control
from .const import (
- ATTR_DURATION,
- ATTR_PERIOD,
- ATTR_SETPOINT,
DOMAIN,
REFRESH_BREAKS_IN_HA_VERSION,
RESET_BREAKS_IN_HA_VERSION,
@@ -49,12 +49,12 @@ def _as_snake_case(mode: str) -> str:
# System service schemas (registered as domain services)
SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# unsupported modes are rejected at runtime with ServiceValidationError
- vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here
- vol.Exclusive(ATTR_DURATION, "temporary"): vol.All(
+ vol.Required(SZ_MODE): cv.string, # ... so, don't use SystemMode enum here
+ vol.Exclusive(SZ_DURATION, "temporary"): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
),
- vol.Exclusive(ATTR_PERIOD, "temporary"): vol.All(
+ vol.Exclusive(SZ_PERIOD, "temporary"): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
),
@@ -63,10 +63,8 @@ SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# Zone service schemas (registered as entity services)
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
- vol.Required(ATTR_SETPOINT): vol.All(
- vol.Coerce(float), vol.Range(min=4.0, max=35.0)
- ),
- vol.Optional(ATTR_DURATION): vol.All(
+ vol.Required(SZ_SETPOINT): vol.All(vol.Coerce(float), vol.Range(min=4.0, max=35.0)),
+ vol.Optional(SZ_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
@@ -74,8 +72,8 @@ SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# DHW service schemas (registered as entity services)
SET_DHW_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
- vol.Required(ATTR_STATE): cv.boolean,
- vol.Optional(ATTR_DURATION): vol.All(
+ vol.Required(SZ_STATE): cv.boolean,
+ vol.Optional(SZ_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
@@ -163,7 +161,7 @@ def _register_dhw_entity_services(hass: HomeAssistant) -> None:
def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None:
"""Validate that a set_system_mode service call is properly formed."""
- mode = data[ATTR_MODE]
+ mode = data[SZ_MODE]
tcs_modes = {m[SZ_SYSTEM_MODE].value: m for m in tcs.allowed_system_modes}
# Validation occurs here, instead of in the library, because it uses a slightly
@@ -174,34 +172,34 @@ def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_not_supported",
- translation_placeholders={ATTR_MODE: mode},
+ translation_placeholders={SZ_MODE: mode},
)
# voluptuous schema ensures that duration and period are not both present
if not mode_info[SZ_CAN_BE_TEMPORARY]:
- if ATTR_DURATION in data or ATTR_PERIOD in data:
+ if SZ_DURATION in data or SZ_PERIOD in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_be_temporary",
- translation_placeholders={ATTR_MODE: mode},
+ translation_placeholders={SZ_MODE: mode},
)
return
timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary
- if timing_mode == SZ_DURATION and ATTR_PERIOD in data:
+ if timing_mode == SZ_DURATION and SZ_PERIOD in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_have_period",
- translation_placeholders={ATTR_MODE: mode},
+ translation_placeholders={SZ_MODE: mode},
)
- if timing_mode == SZ_PERIOD and ATTR_DURATION in data:
+ if timing_mode == SZ_PERIOD and SZ_DURATION in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mode_cant_have_duration",
- translation_placeholders={ATTR_MODE: mode},
+ translation_placeholders={SZ_MODE: mode},
)
@@ -252,8 +250,8 @@ def setup_service_functions(
payload = {
"unique_id": unique_id,
"service": call.service,
- "data": {**call.data, ATTR_MODE: _as_snake_case(call.data[ATTR_MODE])}
- if ATTR_MODE in call.data
+ "data": {**call.data, SZ_MODE: _as_snake_case(call.data[SZ_MODE])}
+ if SZ_MODE in call.data
else call.data,
}
async_dispatcher_send(hass, DOMAIN, payload)
diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py
index 1aa8a2ccb285..dfeaa4d9d58a 100644
--- a/homeassistant/components/ezviz/__init__.py
+++ b/homeassistant/components/ezviz/__init__.py
@@ -14,6 +14,7 @@ from pyezvizapi.exceptions import (
from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import entity_registry as er
from .const import (
ATTR_TYPE_CAMERA,
@@ -110,6 +111,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo
entry, PLATFORMS_BY_TYPE[sensor_type]
)
+ # Remove any existing last_alarm_pic sensor entities that were migrated away.
+ if sensor_type == ATTR_TYPE_CLOUD:
+ entity_registry = er.async_get(hass)
+ entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
+ for entity_entry in entries:
+ unique_id = entity_entry.unique_id
+ if (
+ entity_entry.domain == "sensor"
+ and unique_id is not None
+ and unique_id.endswith(".last_alarm_pic")
+ ):
+ entity_registry.async_remove(entity_entry.entity_id)
+ _LOGGER.debug(
+ "Removed legacy last_alarm_pic sensor entity: %s",
+ entity_entry.entity_id,
+ )
+
return True
diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py
index 19e2701477d6..3e29b9c5f683 100644
--- a/homeassistant/components/ezviz/image.py
+++ b/homeassistant/components/ezviz/image.py
@@ -1,5 +1,6 @@
"""Support EZVIZ last motion image."""
+from datetime import datetime
import logging
from typing import override
@@ -26,6 +27,13 @@ IMAGE_TYPE = ImageEntityDescription(
)
+def _parse_last_alarm_time(last_alarm_time: object) -> datetime | None:
+ """Parse the last alarm time from the coordinator payload."""
+ if not isinstance(last_alarm_time, str):
+ return None
+ return dt_util.parse_datetime(last_alarm_time)
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
@@ -51,9 +59,12 @@ class EzvizLastMotion(EzvizEntity, ImageEntity):
ImageEntity.__init__(self, hass)
self._attr_unique_id = f"{serial}_{IMAGE_TYPE.key}"
self.entity_description = IMAGE_TYPE
- self._attr_image_url = self.data["last_alarm_pic"]
- self._attr_image_last_updated = dt_util.parse_datetime(
- str(self.data["last_alarm_time"])
+ last_alarm_pic = self.data.get("last_alarm_pic")
+ self._attr_image_url = (
+ last_alarm_pic if isinstance(last_alarm_pic, str) else None
+ )
+ self._attr_image_last_updated = _parse_last_alarm_time(
+ self.data.get("last_alarm_time")
)
camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial)
self.alarm_image_password = (
@@ -71,6 +82,8 @@ class EzvizLastMotion(EzvizEntity, ImageEntity):
@override
async def _async_load_image_from_url(self, url: str) -> Image | None:
"""Load an image by url."""
+ if not url:
+ return None
if response := await self._fetch_url(url):
image_data = response.content
if self.data["encrypted"] and self.alarm_image_password is not None:
@@ -96,16 +109,14 @@ class EzvizLastMotion(EzvizEntity, ImageEntity):
@override
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
- if (
- self.data["last_alarm_pic"]
- and self.data["last_alarm_pic"] != self._attr_image_url
- ):
- _LOGGER.debug("Image url changed to %s", self.data["last_alarm_pic"])
+ last_alarm_pic = self.data.get("last_alarm_pic")
+ if last_alarm_pic and last_alarm_pic != self._attr_image_url:
+ _LOGGER.debug("Image url changed to %s", last_alarm_pic)
- self._attr_image_url = self.data["last_alarm_pic"]
+ self._attr_image_url = last_alarm_pic
self._cached_image = None
- self._attr_image_last_updated = dt_util.parse_datetime(
- str(self.data["last_alarm_time"])
+ self._attr_image_last_updated = _parse_last_alarm_time(
+ self.data.get("last_alarm_time")
)
super()._handle_coordinator_update()
diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py
index 64b409e4c80f..25db9826faee 100644
--- a/homeassistant/components/ezviz/sensor.py
+++ b/homeassistant/components/ezviz/sensor.py
@@ -37,11 +37,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
translation_key="seconds_last_trigger",
entity_registry_enabled_default=False,
),
- "last_alarm_pic": SensorEntityDescription(
- key="last_alarm_pic",
- translation_key="last_alarm_pic",
- entity_registry_enabled_default=False,
- ),
"supported_channels": SensorEntityDescription(
key="supported_channels",
translation_key="supported_channels",
diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json
index 818d41c102ce..2cedcd3c78a2 100644
--- a/homeassistant/components/ezviz/strings.json
+++ b/homeassistant/components/ezviz/strings.json
@@ -111,9 +111,6 @@
"alarm_sound_mod": {
"name": "Alarm sound level"
},
- "last_alarm_pic": {
- "name": "Last alarm picture URL"
- },
"last_alarm_time": {
"name": "Last alarm time"
},
diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py
new file mode 100644
index 000000000000..5296e634f099
--- /dev/null
+++ b/homeassistant/components/fan/llm.py
@@ -0,0 +1,38 @@
+"""LLM tools for the fan integration."""
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from . import DOMAIN
+from .intent import INTENT_FAN_SET_SPEED
+
+# Intents owned by this integration that are exposed as LLM tools.
+LLM_INTENTS = (INTENT_FAN_SET_SPEED,)
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return LLM tools for the integration's intents when its domain is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ if not llm_context.assistant:
+ return None
+
+ if not any(
+ async_should_expose(hass, llm_context.assistant, state.entity_id)
+ for state in hass.states.async_all(DOMAIN)
+ ):
+ return None
+
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type in LLM_INTENTS
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py
index 7cbd89cd261c..067e87f559f7 100644
--- a/homeassistant/components/file_upload/__init__.py
+++ b/homeassistant/components/file_upload/__init__.py
@@ -36,7 +36,11 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Generator[Path]:
"""Get an uploaded file.
- File is removed at the end of the context.
+ File is removed at the end of the context. Should be run on the executor thread pool.
+ Create a wrapper function and call that wrapper function using
+ hass.async_add_executor_job. Running this function directly by scheduling an executor
+ job will result in loop blocking teardown code not running on the executor but
+ rather in the loop.
"""
if DOMAIN not in hass.data:
raise ValueError("File does not exist")
diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py
index 6ed1a61eaab3..f96884724983 100644
--- a/homeassistant/components/filter/sensor.py
+++ b/homeassistant/components/filter/sensor.py
@@ -16,23 +16,21 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.recorder import get_instance, history
from homeassistant.components.sensor import (
- ATTR_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
+ SensorEntityCapabilityAttribute,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
- ATTR_ICON,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_ENTITY_ID,
CONF_NAME,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import (
Event,
@@ -307,17 +305,21 @@ class SensorFilter(SensorEntity):
self._state = temp_state.state
- self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON)
- self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS)
- self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS)
+ self._attr_icon = new_state.attributes.get(EntityStateAttribute.ICON, ICON)
+ self._attr_device_class = new_state.attributes.get(
+ EntityStateAttribute.DEVICE_CLASS
+ )
+ self._attr_state_class = new_state.attributes.get(
+ SensorEntityCapabilityAttribute.STATE_CLASS
+ )
if self._attr_native_unit_of_measurement != new_state.attributes.get(
- ATTR_UNIT_OF_MEASUREMENT
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
):
for filt in self._filters:
filt.reset()
self._attr_native_unit_of_measurement = new_state.attributes.get(
- ATTR_UNIT_OF_MEASUREMENT
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
)
if update_ha:
diff --git a/homeassistant/components/fints/__init__.py b/homeassistant/components/fints/__init__.py
index 0113fa752346..030236752ad1 100644
--- a/homeassistant/components/fints/__init__.py
+++ b/homeassistant/components/fints/__init__.py
@@ -1 +1 @@
-"""The fints component."""
+"""The FinTS integration."""
diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py
index 8f84da1c1cf6..f3684930bef0 100644
--- a/homeassistant/components/firefly_iii/config_flow.py
+++ b/homeassistant/components/firefly_iii/config_flow.py
@@ -15,7 +15,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
@@ -174,13 +173,13 @@ class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
)
-class CannotConnect(HomeAssistantError):
+class CannotConnect(Exception):
"""Error to indicate we cannot connect."""
-class InvalidAuth(HomeAssistantError):
+class InvalidAuth(Exception):
"""Error to indicate there is invalid auth."""
-class FireflyClientTimeout(HomeAssistantError):
+class FireflyClientTimeout(Exception):
"""Error to indicate a timeout occurred."""
diff --git a/homeassistant/components/flock/__init__.py b/homeassistant/components/flock/__init__.py
index 1b58d21cff88..cb299b9f4217 100644
--- a/homeassistant/components/flock/__init__.py
+++ b/homeassistant/components/flock/__init__.py
@@ -1 +1 @@
-"""The flock component."""
+"""The Flock integration."""
diff --git a/homeassistant/components/fluss/quality_scale.yaml b/homeassistant/components/fluss/quality_scale.yaml
index 371598661704..83aa1eba9f52 100644
--- a/homeassistant/components/fluss/quality_scale.yaml
+++ b/homeassistant/components/fluss/quality_scale.yaml
@@ -32,6 +32,7 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
+ entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: done
diff --git a/homeassistant/components/foobot/__init__.py b/homeassistant/components/foobot/__init__.py
index 92edde9a5e1c..8afa3abd2348 100644
--- a/homeassistant/components/foobot/__init__.py
+++ b/homeassistant/components/foobot/__init__.py
@@ -1 +1 @@
-"""The foobot component."""
+"""The Foobot integration."""
diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py
index 684b5b3cbeb9..139db1a604fd 100644
--- a/homeassistant/components/fritz/button.py
+++ b/homeassistant/components/fritz/button.py
@@ -87,7 +87,7 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None:
domain=DOMAIN,
issue_id="deprecated_cleanup_button",
is_fixable=False,
- is_persistent=True,
+ is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_cleanup_button",
translation_placeholders={"removal_version": "2026.11.0"},
@@ -114,7 +114,7 @@ def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) -
domain=DOMAIN,
issue_id="deprecated_firmware_update_button",
is_fixable=False,
- is_persistent=True,
+ is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_firmware_update_button",
translation_placeholders={"removal_version": "2026.11.0"},
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index fcad98ef9f9e..aa043e825993 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -743,9 +743,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
):
if not any(con in device.connections for con in valid_connections):
_LOGGER.debug("Removing obsolete device entry %s", device.name)
- device_reg.async_update_device(
- device.id, remove_config_entry_id=config_entry.entry_id
- )
+ device_reg.async_remove_device(device.id)
fritz_data = self.hass.data[FRITZ_DATA_KEY]
diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py
index 496c04f2e055..1518ccfaa4df 100644
--- a/homeassistant/components/fritzbox/coordinator.py
+++ b/homeassistant/components/fritzbox/coordinator.py
@@ -121,9 +121,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
):
if not set(device.identifiers) & identifiers:
LOGGER.debug("Removing obsolete device entry %s", device.name)
- device_reg.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_reg.async_remove_device(device.id)
def _update_fritz_devices(self) -> FritzboxCoordinatorData:
"""Update all fritzbox device data."""
diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py
index e88227fe33ca..ea147ee2276e 100644
--- a/homeassistant/components/fronius/__init__.py
+++ b/homeassistant/components/fronius/__init__.py
@@ -35,7 +35,7 @@ from .coordinator import (
)
_LOGGER: Final = logging.getLogger(__name__)
-PLATFORMS: Final = [Platform.SENSOR]
+PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR]
type FroniusConfigEntry = ConfigEntry[FroniusSolarNet]
diff --git a/homeassistant/components/fronius/binary_sensor.py b/homeassistant/components/fronius/binary_sensor.py
new file mode 100644
index 000000000000..4db1bced11ca
--- /dev/null
+++ b/homeassistant/components/fronius/binary_sensor.py
@@ -0,0 +1,85 @@
+"""Support for Fronius binary sensors."""
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, override
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory, Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .entity import FroniusEntity, FroniusEntityDescription
+
+if TYPE_CHECKING:
+ from . import FroniusConfigEntry
+ from .coordinator import FroniusPowerFlowUpdateCoordinator
+
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True)
+class FroniusBinarySensorEntityDescription(
+ FroniusEntityDescription, BinarySensorEntityDescription
+):
+ """Describes Fronius binary sensor entity."""
+
+
+POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS: list[FroniusBinarySensorEntityDescription] = [
+ FroniusBinarySensorEntityDescription(
+ key="backup_mode",
+ ),
+ FroniusBinarySensorEntityDescription(
+ key="battery_standby",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: FroniusConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Fronius binary sensor entities based on a config entry."""
+ solar_net = config_entry.runtime_data
+ if solar_net.power_flow_coordinator is not None:
+ solar_net.power_flow_coordinator.add_entities_for_seen_keys(
+ async_add_entities, Platform.BINARY_SENSOR, PowerFlowBinarySensor
+ )
+
+
+class PowerFlowBinarySensor(FroniusEntity, BinarySensorEntity):
+ """Defines a Fronius power flow binary sensor entity."""
+
+ entity_description: FroniusBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: FroniusPowerFlowUpdateCoordinator,
+ description: FroniusBinarySensorEntityDescription,
+ solar_net_id: str,
+ ) -> None:
+ """Set up an individual Fronius power flow binary sensor."""
+ super().__init__(coordinator, description, solar_net_id)
+ self._attr_is_on = self._device_data()[self.response_key]["value"]
+ # SolarNet device is already created in FroniusSolarNet._create_solar_net_device
+ self._attr_device_info = coordinator.solar_net.system_device_info
+ self._attr_unique_id = (
+ f"{coordinator.solar_net.solar_net_device_id}-power_flow-{description.key}"
+ )
+
+ @callback
+ @override
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ try:
+ self._attr_is_on = self._device_data()[self.response_key]["value"]
+ except KeyError:
+ # KeyError: raised when omitted in response, e.g. when backup power
+ # is deactivated after the entity was created
+ self._attr_is_on = None
+ self.async_write_ha_state()
diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py
index 302d278880da..17f70f8e4d4e 100644
--- a/homeassistant/components/fronius/coordinator.py
+++ b/homeassistant/components/fronius/coordinator.py
@@ -1,15 +1,18 @@
"""DataUpdateCoordinators for the Fronius integration."""
from abc import ABC, abstractmethod
+from collections.abc import Mapping, Sequence
from datetime import timedelta
from typing import TYPE_CHECKING, Any, override
from pyfronius import BadStatusError, FroniusError
+from homeassistant.const import Platform
from homeassistant.core import callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from .binary_sensor import POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS
from .const import (
DOMAIN,
SOLAR_NET_ID_POWER_FLOW,
@@ -17,6 +20,7 @@ from .const import (
FroniusDeviceInfo,
SolarNetId,
)
+from .entity import FroniusEntity, FroniusEntityDescription
from .sensor import (
INVERTER_ENTITY_DESCRIPTIONS,
LOGGER_ENTITY_DESCRIPTIONS,
@@ -24,12 +28,10 @@ from .sensor import (
OHMPILOT_ENTITY_DESCRIPTIONS,
POWER_FLOW_ENTITY_DESCRIPTIONS,
STORAGE_ENTITY_DESCRIPTIONS,
- FroniusSensorEntityDescription,
)
if TYPE_CHECKING:
from . import FroniusSolarNet
- from .sensor import _FroniusSensorEntity
class FroniusCoordinatorBase(
@@ -39,7 +41,7 @@ class FroniusCoordinatorBase(
default_interval: timedelta
error_interval: timedelta
- valid_descriptions: list[FroniusSensorEntityDescription]
+ valid_descriptions: Mapping[Platform, Sequence[FroniusEntityDescription]]
MAX_FAILED_UPDATES = 3
@@ -49,7 +51,7 @@ class FroniusCoordinatorBase(
self.solar_net = solar_net
# unregistered_descriptors are used to create entities in platform module
self.unregistered_descriptors: dict[
- SolarNetId, list[FroniusSensorEntityDescription]
+ SolarNetId, dict[Platform, list[FroniusEntityDescription]]
] = {}
super().__init__(*args, update_interval=self.default_interval, **kwargs)
@@ -80,15 +82,17 @@ class FroniusCoordinatorBase(
for solar_net_id in data:
if solar_net_id not in self.unregistered_descriptors:
# id seen for the first time
- self.unregistered_descriptors[solar_net_id] = (
- self.valid_descriptions.copy()
- )
+ self.unregistered_descriptors[solar_net_id] = {
+ platform: list(descriptions)
+ for platform, descriptions in self.valid_descriptions.items()
+ }
return data
@callback
- def add_entities_for_seen_keys[_FroniusEntityT: _FroniusSensorEntity](
+ def add_entities_for_seen_keys[_FroniusEntityT: FroniusEntity](
self,
async_add_entities: AddEntitiesCallback,
+ platform: Platform,
entity_constructor: type[_FroniusEntityT],
) -> None:
"""Add entities for received keys and registers listener for future seen keys.
@@ -102,7 +106,9 @@ class FroniusCoordinatorBase(
new_entities: list[_FroniusEntityT] = []
for solar_net_id, device_data in self.data.items():
remaining_unregistered_descriptors = []
- for description in self.unregistered_descriptors[solar_net_id]:
+ for description in self.unregistered_descriptors[solar_net_id][
+ platform
+ ]:
key = description.response_key or description.key
if key not in device_data:
remaining_unregistered_descriptors.append(description)
@@ -117,7 +123,7 @@ class FroniusCoordinatorBase(
solar_net_id=solar_net_id,
)
)
- self.unregistered_descriptors[solar_net_id] = (
+ self.unregistered_descriptors[solar_net_id][platform] = (
remaining_unregistered_descriptors
)
async_add_entities(new_entities)
@@ -133,7 +139,7 @@ class FroniusInverterUpdateCoordinator(FroniusCoordinatorBase):
default_interval = timedelta(minutes=1)
error_interval = timedelta(minutes=10)
- valid_descriptions = INVERTER_ENTITY_DESCRIPTIONS
+ valid_descriptions = {Platform.SENSOR: INVERTER_ENTITY_DESCRIPTIONS}
SILENT_RETRIES = 3
@@ -170,7 +176,7 @@ class FroniusLoggerUpdateCoordinator(FroniusCoordinatorBase):
default_interval = timedelta(hours=1)
error_interval = timedelta(hours=1)
- valid_descriptions = LOGGER_ENTITY_DESCRIPTIONS
+ valid_descriptions = {Platform.SENSOR: LOGGER_ENTITY_DESCRIPTIONS}
@override
async def _update_method(self) -> dict[SolarNetId, Any]:
@@ -184,7 +190,7 @@ class FroniusMeterUpdateCoordinator(FroniusCoordinatorBase):
default_interval = timedelta(minutes=1)
error_interval = timedelta(minutes=10)
- valid_descriptions = METER_ENTITY_DESCRIPTIONS
+ valid_descriptions = {Platform.SENSOR: METER_ENTITY_DESCRIPTIONS}
@override
async def _update_method(self) -> dict[SolarNetId, Any]:
@@ -198,7 +204,7 @@ class FroniusOhmpilotUpdateCoordinator(FroniusCoordinatorBase):
default_interval = timedelta(minutes=1)
error_interval = timedelta(minutes=10)
- valid_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS
+ valid_descriptions = {Platform.SENSOR: OHMPILOT_ENTITY_DESCRIPTIONS}
@override
async def _update_method(self) -> dict[SolarNetId, Any]:
@@ -212,7 +218,10 @@ class FroniusPowerFlowUpdateCoordinator(FroniusCoordinatorBase):
default_interval = timedelta(seconds=10)
error_interval = timedelta(minutes=3)
- valid_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS
+ valid_descriptions = {
+ Platform.SENSOR: POWER_FLOW_ENTITY_DESCRIPTIONS,
+ Platform.BINARY_SENSOR: POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS,
+ }
@override
async def _update_method(self) -> dict[SolarNetId, Any]:
@@ -226,7 +235,7 @@ class FroniusStorageUpdateCoordinator(FroniusCoordinatorBase):
default_interval = timedelta(minutes=1)
error_interval = timedelta(minutes=10)
- valid_descriptions = STORAGE_ENTITY_DESCRIPTIONS
+ valid_descriptions = {Platform.SENSOR: STORAGE_ENTITY_DESCRIPTIONS}
@override
async def _update_method(self) -> dict[SolarNetId, Any]:
diff --git a/homeassistant/components/fronius/entity.py b/homeassistant/components/fronius/entity.py
new file mode 100644
index 000000000000..daa7bac527bc
--- /dev/null
+++ b/homeassistant/components/fronius/entity.py
@@ -0,0 +1,42 @@
+"""Base entity for the Fronius integration."""
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+if TYPE_CHECKING:
+ from .coordinator import FroniusCoordinatorBase
+
+
+@dataclass(frozen=True)
+class FroniusEntityDescription(EntityDescription):
+ """Base class for Fronius entity descriptions."""
+
+ response_key: str | None = None
+
+
+class FroniusEntity(CoordinatorEntity["FroniusCoordinatorBase"]):
+ """Defines a Fronius coordinator entity."""
+
+ entity_description: FroniusEntityDescription
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: FroniusCoordinatorBase,
+ description: FroniusEntityDescription,
+ solar_net_id: str,
+ ) -> None:
+ """Set up an individual Fronius coordinator entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self.response_key = description.response_key or description.key
+ self.solar_net_id = solar_net_id
+ self._attr_translation_key = description.translation_key or description.key
+
+ def _device_data(self) -> dict[str, Any]:
+ """Extract information for SolarNet device from coordinator data."""
+ return self.coordinator.data[self.solar_net_id]
diff --git a/homeassistant/components/fronius/icons.json b/homeassistant/components/fronius/icons.json
index e899dcf32822..40d598b2a528 100644
--- a/homeassistant/components/fronius/icons.json
+++ b/homeassistant/components/fronius/icons.json
@@ -1,5 +1,13 @@
{
"entity": {
+ "binary_sensor": {
+ "backup_mode": {
+ "default": "mdi:home-battery"
+ },
+ "battery_standby": {
+ "default": "mdi:power-standby"
+ }
+ },
"sensor": {
"cash_factor": {
"default": "mdi:cash-plus"
diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py
index 8a32acaee6aa..6bcbde7cd72c 100644
--- a/homeassistant/components/fronius/sensor.py
+++ b/homeassistant/components/fronius/sensor.py
@@ -13,6 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
+ Platform,
UnitOfApparentPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
@@ -27,7 +28,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
@@ -40,6 +40,7 @@ from .const import (
get_meter_location_description,
get_ohmpilot_state_message,
)
+from .entity import FroniusEntity, FroniusEntityDescription
if TYPE_CHECKING:
from . import FroniusConfigEntry
@@ -69,33 +70,35 @@ async def async_setup_entry(
for inverter_coordinator in solar_net.inverter_coordinators:
inverter_coordinator.add_entities_for_seen_keys(
- async_add_entities, InverterSensor
+ async_add_entities, Platform.SENSOR, InverterSensor
)
if solar_net.logger_coordinator is not None:
solar_net.logger_coordinator.add_entities_for_seen_keys(
- async_add_entities, LoggerSensor
+ async_add_entities, Platform.SENSOR, LoggerSensor
)
if solar_net.meter_coordinator is not None:
solar_net.meter_coordinator.add_entities_for_seen_keys(
- async_add_entities, MeterSensor
+ async_add_entities, Platform.SENSOR, MeterSensor
)
if solar_net.ohmpilot_coordinator is not None:
solar_net.ohmpilot_coordinator.add_entities_for_seen_keys(
- async_add_entities, OhmpilotSensor
+ async_add_entities, Platform.SENSOR, OhmpilotSensor
)
if solar_net.power_flow_coordinator is not None:
solar_net.power_flow_coordinator.add_entities_for_seen_keys(
- async_add_entities, PowerFlowSensor
+ async_add_entities, Platform.SENSOR, PowerFlowSensor
)
if solar_net.storage_coordinator is not None:
solar_net.storage_coordinator.add_entities_for_seen_keys(
- async_add_entities, StorageSensor
+ async_add_entities, Platform.SENSOR, StorageSensor
)
@callback
def async_add_new_entities(coordinator: FroniusInverterUpdateCoordinator) -> None:
"""Add newly found inverter entities."""
- coordinator.add_entities_for_seen_keys(async_add_entities, InverterSensor)
+ coordinator.add_entities_for_seen_keys(
+ async_add_entities, Platform.SENSOR, InverterSensor
+ )
config_entry.async_on_unload(
async_dispatcher_connect(
@@ -107,14 +110,13 @@ async def async_setup_entry(
@dataclass(frozen=True)
-class FroniusSensorEntityDescription(SensorEntityDescription):
+class FroniusSensorEntityDescription(FroniusEntityDescription, SensorEntityDescription):
"""Describes Fronius sensor entity."""
default_value: StateType | None = None
# Gen24 devices may report 0 for total energy while doing firmware updates.
# Handling such values shall mitigate spikes in delta calculations.
invalid_when_falsy: bool = False
- response_key: str | None = None
value_fn: Callable[[StateType], StateType] | None = None
@@ -746,13 +748,11 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
]
-class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEntity):
- """Defines a Fronius coordinator entity."""
+class _FroniusSensorEntity(FroniusEntity, SensorEntity):
+ """Defines a Fronius coordinator sensor entity."""
entity_description: FroniusSensorEntityDescription
- _attr_has_entity_name = True
-
def __init__(
self,
coordinator: FroniusCoordinatorBase,
@@ -760,16 +760,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
solar_net_id: str,
) -> None:
"""Set up an individual Fronius meter sensor."""
- super().__init__(coordinator)
- self.entity_description = description
- self.response_key = description.response_key or description.key
- self.solar_net_id = solar_net_id
+ super().__init__(coordinator, description, solar_net_id)
self._attr_native_value = self._get_entity_value()
- self._attr_translation_key = description.translation_key or description.key
-
- def _device_data(self) -> dict[str, Any]:
- """Extract information for SolarNet device from coordinator data."""
- return self.coordinator.data[self.solar_net_id]
def _get_entity_value(self) -> Any:
"""Extract entity value from coordinator.
diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json
index 2c742d0d89c9..44358e1d1845 100644
--- a/homeassistant/components/fronius/strings.json
+++ b/homeassistant/components/fronius/strings.json
@@ -36,6 +36,14 @@
}
},
"entity": {
+ "binary_sensor": {
+ "backup_mode": {
+ "name": "Backup mode"
+ },
+ "battery_standby": {
+ "name": "Battery standby"
+ }
+ },
"sensor": {
"capacity_designed": {
"name": "Designed capacity"
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 14996cd487e2..19724c285043 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20260624.4"]
+ "requirements": ["home-assistant-frontend==20260624.6"]
}
diff --git a/homeassistant/components/fuelprices_dk/__init__.py b/homeassistant/components/fuelprices_dk/__init__.py
new file mode 100644
index 000000000000..a26a8552e228
--- /dev/null
+++ b/homeassistant/components/fuelprices_dk/__init__.py
@@ -0,0 +1,53 @@
+"""Initialize the Fuelprices.dk component."""
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, Platform
+from homeassistant.core import HomeAssistant
+
+from .const import CONF_COMPANY, CONF_STATION, SUBENTRY_TYPE_STATION
+from .coordinator import FuelPricesDKCoordinator
+
+PLATFORMS = [Platform.SENSOR]
+
+type FuelpricesDkConfigEntry = ConfigEntry[dict[str, FuelPricesDKCoordinator]]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: FuelpricesDkConfigEntry
+) -> bool:
+ """Set up Fuelprices.dk from a config entry."""
+ config_entry.async_on_unload(config_entry.add_update_listener(_update_listener))
+ api_key = config_entry.data[CONF_API_KEY]
+ runtime_data: dict[str, FuelPricesDKCoordinator] = {}
+
+ for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_STATION):
+ subentry_id = subentry.subentry_id
+ company = subentry.data[CONF_COMPANY]
+ station = subentry.data[CONF_STATION]
+
+ coordinator = FuelPricesDKCoordinator(
+ hass,
+ api_key,
+ company,
+ station,
+ subentry_id,
+ config_entry,
+ )
+ runtime_data[subentry_id] = coordinator
+ await coordinator.async_config_entry_first_refresh()
+
+ config_entry.runtime_data = runtime_data
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+ return True
+
+
+async def _update_listener(hass: HomeAssistant, entry: FuelpricesDkConfigEntry) -> None:
+ """Handle options or subentry updates by reloading the entry."""
+ hass.config_entries.async_schedule_reload(entry.entry_id)
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: FuelpricesDkConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
diff --git a/homeassistant/components/fuelprices_dk/config_flow.py b/homeassistant/components/fuelprices_dk/config_flow.py
new file mode 100644
index 000000000000..e3acf6563beb
--- /dev/null
+++ b/homeassistant/components/fuelprices_dk/config_flow.py
@@ -0,0 +1,369 @@
+"""Config flow for the Fuelprices.dk integration."""
+
+from collections.abc import Mapping
+from typing import Any, override
+
+from aiohttp import ClientResponseError
+from pybraendstofpriser import Braendstofpriser
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ ConfigSubentryFlow,
+ SubentryFlowResult,
+)
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import callback
+
+from .const import CONF_COMPANY, CONF_STATION, DOMAIN, WEBSITE_URL
+
+
+def _get_api_error_key(exc: ClientResponseError) -> str:
+ """Map API errors to config flow errors."""
+ if exc.status == 401:
+ return "invalid_api_key"
+ if exc.status == 429:
+ return "rate_limit_exceeded"
+ return "cannot_connect"
+
+
+class FuelpricesDkConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Fuelprices.dk."""
+
+ VERSION = 1
+
+ @classmethod
+ @callback
+ @override
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this handler."""
+ return {"station": FuelpricesDkStationSubentryFlow}
+
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ self.api: Braendstofpriser
+ self.companies: list[dict[str, Any]] = []
+ self.stations: Any = {}
+ self.company_name = ""
+ self.user_input: dict[str, Any] = {}
+
+ async def _async_validate_api_key(
+ self, api_key: str
+ ) -> tuple[Braendstofpriser | None, list[dict[str, Any]], str | None]:
+ """Validate the API key and fetch available companies."""
+ api = Braendstofpriser(api_key)
+ try:
+ companies = await api.list_companies()
+ except ClientResponseError as exc:
+ return None, [], _get_api_error_key(exc)
+
+ if not companies:
+ return None, [], "cannot_connect"
+
+ return api, companies, None
+
+ async def _async_fetch_stations(self, company_name: str) -> tuple[Any, str | None]:
+ """Fetch stations for a company."""
+ try:
+ stations = await self.api.list_stations(company_name=company_name)
+ except ClientResponseError as exc:
+ return None, _get_api_error_key(exc)
+
+ if not stations:
+ return None, "cannot_connect"
+
+ return stations, None
+
+ def _show_company_selection_form(self, errors: dict[str, str]) -> ConfigFlowResult:
+ """Show the company selection form."""
+ return self.async_show_form(
+ step_id="company_selection",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_COMPANY, default=self.company_name): vol.In(
+ [c["company"] for c in self.companies]
+ ),
+ }
+ ),
+ errors=errors,
+ )
+
+ @override
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step - Enter API key."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ self._async_abort_entries_match(user_input)
+ api, companies, error = await self._async_validate_api_key(
+ user_input[CONF_API_KEY]
+ )
+ if error is None:
+ assert api is not None
+ self.api = api
+ self.companies = companies
+ self.user_input = dict(user_input)
+ return await self.async_step_company_selection()
+
+ errors["base"] = error
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ }
+ ),
+ errors=errors,
+ description_placeholders={"website_url": WEBSITE_URL},
+ )
+
+ async def async_step_company_selection(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the company selection step."""
+ if user_input is not None:
+ self.company_name = user_input[CONF_COMPANY]
+ self.user_input.update(user_input)
+ self.stations = {}
+ return await self.async_step_station_selection()
+
+ return self._show_company_selection_form({})
+
+ async def async_step_station_selection(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the station selection step."""
+ if not self.stations:
+ stations, error = await self._async_fetch_stations(self.company_name)
+ if error is not None:
+ return self._show_company_selection_form({"base": error})
+ self.stations = stations
+
+ if user_input is not None:
+ user_input[CONF_STATION] = self.stations.find(
+ "name", user_input[CONF_STATION]
+ )
+
+ # Create the main config entry with the first station subentry
+ self.user_input.update(user_input)
+ unique_id = (
+ f"{self.user_input[CONF_COMPANY]}_{self.user_input[CONF_STATION]['id']}"
+ )
+ title = (
+ f"{self.user_input[CONF_COMPANY]} - "
+ f"{self.user_input[CONF_STATION]['name']}"
+ )
+ return self.async_create_entry(
+ title="Fuelprices.dk",
+ data={CONF_API_KEY: self.user_input[CONF_API_KEY]},
+ subentries=[
+ {
+ "subentry_type": "station",
+ "data": {
+ CONF_COMPANY: self.user_input[CONF_COMPANY],
+ CONF_STATION: self.user_input[CONF_STATION],
+ },
+ "title": title,
+ "unique_id": unique_id,
+ }
+ ],
+ )
+
+ stations = [s["name"] for s in self.stations]
+
+ return self.async_show_form(
+ step_id="station_selection",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_STATION): vol.In(stations),
+ }
+ ),
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle a reauth flow when API key is invalid/expired."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm a new API key."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ api = Braendstofpriser(user_input[CONF_API_KEY])
+ try:
+ await api.list_companies()
+ except ClientResponseError as exc:
+ errors["base"] = _get_api_error_key(exc)
+
+ if not errors:
+ entry = self.hass.config_entries.async_get_entry(
+ self.context["entry_id"]
+ )
+ if entry is not None:
+ self.hass.config_entries.async_update_entry(
+ entry,
+ data={CONF_API_KEY: user_input[CONF_API_KEY]},
+ )
+ self.hass.config_entries.async_schedule_reload(entry.entry_id)
+ return self.async_abort(reason="reauth_successful")
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
+ errors=errors,
+ )
+
+
+class FuelpricesDkStationSubentryFlow(ConfigSubentryFlow):
+ """Handle station subentries for Fuelprices.dk."""
+
+ def __init__(self) -> None:
+ """Initialize the subentry flow."""
+ self.api: Braendstofpriser
+ self.companies: list[dict[str, Any]] = []
+ self.stations: Any = {}
+ self.company_name = ""
+ self._errors: dict[str, str] = {}
+ self.user_input: dict[str, Any] = {}
+
+ async def _async_fetch_stations(self, company_name: str) -> tuple[Any, str | None]:
+ """Fetch stations for a company."""
+ try:
+ stations = await self.api.list_stations(company_name=company_name)
+ except ClientResponseError as exc:
+ return None, _get_api_error_key(exc)
+
+ if not stations:
+ return None, "cannot_connect"
+
+ return stations, None
+
+ def _show_company_selection_form(
+ self, errors: dict[str, str] | None = None
+ ) -> SubentryFlowResult:
+ """Show the company selection form."""
+ default_company = self.user_input.get(CONF_COMPANY)
+ company_field = (
+ vol.Required(CONF_COMPANY, default=default_company)
+ if default_company
+ else vol.Required(CONF_COMPANY)
+ )
+
+ return self.async_show_form(
+ step_id="company_selection",
+ data_schema=vol.Schema(
+ {
+ company_field: vol.In([c["company"] for c in self.companies]),
+ }
+ ),
+ errors=errors or {},
+ )
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Handle the initial step for adding a station subentry."""
+ await self._async_init_api()
+ return await self.async_step_company_selection(user_input)
+
+ async def _async_init_api(self) -> None:
+ """Initialize API client and fetch companies."""
+ entry = self._get_entry()
+ api_key = entry.data[CONF_API_KEY]
+ self.api = Braendstofpriser(api_key)
+ try:
+ self.companies = await self.api.list_companies()
+ except ClientResponseError as exc:
+ self._errors["base"] = _get_api_error_key(exc)
+ return
+
+ if not self.companies:
+ self._errors["base"] = "cannot_connect"
+
+ async def async_step_company_selection(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Handle the company selection step."""
+ if self._errors:
+ return self.async_abort(reason=self._errors["base"])
+
+ if user_input is not None:
+ self.company_name = user_input[CONF_COMPANY]
+ self.user_input.update(user_input)
+ self.stations = {}
+ return await self.async_step_station_selection()
+
+ return self._show_company_selection_form()
+
+ async def async_step_station_selection(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Handle the station selection step."""
+ if not self.stations:
+ stations, error = await self._async_fetch_stations(self.company_name)
+ if error is not None:
+ self.user_input[CONF_COMPANY] = self.company_name
+ return self._show_company_selection_form({"base": error})
+ self.stations = stations
+
+ if user_input is not None:
+ user_input[CONF_STATION] = self.stations.find(
+ "name", user_input[CONF_STATION]
+ )
+
+ # Set UniqueID and abort if already existing
+ unique_id = (
+ f"{self.user_input[CONF_COMPANY]}_{user_input[CONF_STATION]['id']}"
+ )
+ entry = self._get_entry()
+ for subentry in entry.subentries.values():
+ if subentry.unique_id == unique_id:
+ return self.async_abort(reason="station_already_configured")
+
+ # Process the user input and show next selection form
+ self.user_input.update(user_input)
+ return await self._async_create_or_update_subentry()
+
+ stations = [s["name"] for s in self.stations]
+
+ return self.async_show_form(
+ step_id="station_selection",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_STATION): vol.In(stations),
+ }
+ ),
+ errors=self._errors,
+ )
+
+ async def _async_create_or_update_subentry(self) -> SubentryFlowResult:
+ """Create the station subentry."""
+ subentry_data = {
+ CONF_COMPANY: self.user_input[CONF_COMPANY],
+ CONF_STATION: self.user_input[CONF_STATION],
+ }
+ unique_id = (
+ f"{self.user_input[CONF_COMPANY]}_{self.user_input[CONF_STATION]['id']}"
+ )
+ title = (
+ f"{self.user_input[CONF_COMPANY]} - {self.user_input[CONF_STATION]['name']}"
+ )
+
+ entry = self._get_entry()
+ self.hass.config_entries.async_schedule_reload(entry.entry_id)
+ return self.async_create_entry(
+ title=title,
+ data=subentry_data,
+ unique_id=unique_id,
+ )
diff --git a/homeassistant/components/fuelprices_dk/const.py b/homeassistant/components/fuelprices_dk/const.py
new file mode 100644
index 000000000000..654754113558
--- /dev/null
+++ b/homeassistant/components/fuelprices_dk/const.py
@@ -0,0 +1,10 @@
+"""Constants for the Fuelprices.dk integration."""
+
+DOMAIN = "fuelprices_dk"
+
+CONF_COMPANY = "company"
+CONF_STATION = "station"
+
+SUBENTRY_TYPE_STATION = "station"
+
+WEBSITE_URL = "https://fuelprices.dk"
diff --git a/homeassistant/components/fuelprices_dk/coordinator.py b/homeassistant/components/fuelprices_dk/coordinator.py
new file mode 100644
index 000000000000..11ccdf84eada
--- /dev/null
+++ b/homeassistant/components/fuelprices_dk/coordinator.py
@@ -0,0 +1,64 @@
+"""Coordinator for the Fuelprices.dk integration."""
+
+from datetime import timedelta
+import logging
+from typing import TYPE_CHECKING, Any, override
+
+from aiohttp import ClientResponseError
+from pybraendstofpriser import Braendstofpriser
+from pybraendstofpriser.exceptions import ProductNotFoundError
+
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+if TYPE_CHECKING:
+ from . import FuelpricesDkConfigEntry
+
+SCAN_INTERVAL = timedelta(hours=1)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class FuelPricesDKCoordinator(DataUpdateCoordinator[dict[str, float | None]]):
+ """Data update coordinator for the Fuelprices.dk integration."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ api_key: str,
+ company: str,
+ station: dict[str, Any],
+ subentry_id: str,
+ config_entry: FuelpricesDkConfigEntry,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass=hass,
+ name=company,
+ logger=_LOGGER,
+ update_interval=SCAN_INTERVAL,
+ config_entry=config_entry,
+ )
+
+ self._api = Braendstofpriser(api_key)
+ self.company = company
+ self.station_id: int = station["id"]
+ self.station_name: str = station["name"]
+ self.subentry_id = subentry_id
+
+ @override
+ async def _async_update_data(self) -> dict[str, float | None]:
+ """Handle data update request from the coordinator."""
+ try:
+ data = await self._api.get_prices(self.station_id)
+ except ProductNotFoundError as exc:
+ raise ConfigEntryError(exc) from exc
+ except ClientResponseError as exc:
+ if exc.status == 401:
+ raise ConfigEntryAuthFailed(exc) from exc
+ raise ConfigEntryError(exc) from exc
+
+ self.station_name = data["station"]["name"]
+
+ return dict(data["prices"])
diff --git a/homeassistant/components/fuelprices_dk/manifest.json b/homeassistant/components/fuelprices_dk/manifest.json
new file mode 100644
index 000000000000..5ffa98f952cc
--- /dev/null
+++ b/homeassistant/components/fuelprices_dk/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "fuelprices_dk",
+ "name": "Fuelprices.dk",
+ "codeowners": ["@MTrab"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/fuelprices_dk",
+ "integration_type": "hub",
+ "iot_class": "cloud_polling",
+ "quality_scale": "bronze",
+ "requirements": ["pybraendstofpriser==2.2.0"]
+}
diff --git a/homeassistant/components/fuelprices_dk/quality_scale.yaml b/homeassistant/components/fuelprices_dk/quality_scale.yaml
new file mode 100644
index 000000000000..d79be918fca4
--- /dev/null
+++ b/homeassistant/components/fuelprices_dk/quality_scale.yaml
@@ -0,0 +1,86 @@
+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: |
+ The integration does not provide any additional actions.
+ docs-conditions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: |
+ The integration does not provide any additional triggers.
+ 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 provide any additional actions.
+ 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:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it connects to a cloud service.
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it connects to a cloud service.
+ 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: done
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: |
+ The integration does not provide any additional icons.
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/fuelprices_dk/sensor.py b/homeassistant/components/fuelprices_dk/sensor.py
new file mode 100644
index 000000000000..459183cca682
--- /dev/null
+++ b/homeassistant/components/fuelprices_dk/sensor.py
@@ -0,0 +1,103 @@
+"""Sensor platform for the Fuelprices.dk integration."""
+
+from typing import TYPE_CHECKING, override
+
+from homeassistant.components.sensor import (
+ RestoreSensor,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.util import slugify as util_slugify
+
+from .const import DOMAIN
+from .coordinator import FuelPricesDKCoordinator
+
+if TYPE_CHECKING:
+ from . import FuelpricesDkConfigEntry
+
+SENSORS = [
+ SensorEntityDescription(
+ key="price",
+ name="Fuel Price",
+ native_unit_of_measurement="DKK/L",
+ state_class=SensorStateClass.MEASUREMENT,
+ icon="mdi:gas-station",
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: FuelpricesDkConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the sensor platform for Fuelprices.dk."""
+
+ for coordinator in entry.runtime_data.values():
+ async_add_entities(
+ (
+ FuelpricesDkSensor(
+ coordinator,
+ coordinator.station_name,
+ product_key,
+ sensor,
+ )
+ for sensor in SENSORS
+ for product_key in coordinator.data
+ ),
+ config_subentry_id=coordinator.subentry_id,
+ )
+
+
+class FuelpricesDkSensor(CoordinatorEntity[FuelPricesDKCoordinator], RestoreSensor):
+ """Sensor for Fuelprices.dk."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: FuelPricesDKCoordinator,
+ station_name: str,
+ product_key: str,
+ description: SensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self.entity_description = description
+
+ self._product_key = product_key
+ self._station_name = station_name
+
+ self._attr_name = product_key
+
+ self._attr_unique_id = util_slugify(
+ f"{self.coordinator.station_id}_{self.entity_description.key}_{product_key}"
+ )
+ self._attr_config_subentry_id = self.coordinator.subentry_id
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, str(self.coordinator.station_id))},
+ entry_type=DeviceEntryType.SERVICE,
+ name=self._station_name,
+ manufacturer=self.coordinator.company,
+ model=self.coordinator.station_name,
+ )
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return whether the entity is available."""
+ return super().available and self._product_key in self.coordinator.data
+
+ @property
+ @override
+ def native_value(self) -> float | None:
+ """Return the current value of the sensor."""
+ price = self.coordinator.data[self._product_key]
+ if isinstance(price, int | float):
+ return float(price)
+ return None
diff --git a/homeassistant/components/fuelprices_dk/strings.json b/homeassistant/components/fuelprices_dk/strings.json
new file mode 100644
index 000000000000..ad8337531f3f
--- /dev/null
+++ b/homeassistant/components/fuelprices_dk/strings.json
@@ -0,0 +1,94 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "This API key is already configured.",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_api_key": "Invalid API key provided",
+ "rate_limit_exceeded": "Too many requests to the API. Please try again later.",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "station_already_configured": "The selected station for this company is already configured"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]",
+ "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]"
+ },
+ "step": {
+ "company_selection": {
+ "data": {
+ "company": "Select company"
+ },
+ "data_description": {
+ "company": "The company you want to fetch prices from"
+ },
+ "description": "Select company"
+ },
+ "reauth_confirm": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "Your personal API key"
+ },
+ "description": "Your API key needs to be updated."
+ },
+ "station_selection": {
+ "data": {
+ "station": "Select station"
+ },
+ "data_description": {
+ "station": "The station you want to create sensors for"
+ },
+ "description": "Select station to show prices for"
+ },
+ "user": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "Your personal API key"
+ },
+ "description": "Enter your Fuelprices.dk API key\nIf you do not have an API key, you can get one for free at {website_url}"
+ }
+ }
+ },
+ "config_subentries": {
+ "station": {
+ "abort": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]",
+ "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]",
+ "station_already_configured": "[%key:component::fuelprices_dk::config::abort::station_already_configured%]"
+ },
+ "entry_type": "Station",
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]",
+ "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]"
+ },
+ "initiate_flow": {
+ "user": "Add station"
+ },
+ "step": {
+ "company_selection": {
+ "data": {
+ "company": "[%key:component::fuelprices_dk::config::step::company_selection::data::company%]"
+ },
+ "data_description": {
+ "company": "[%key:component::fuelprices_dk::config::step::company_selection::data_description::company%]"
+ },
+ "description": "[%key:component::fuelprices_dk::config::step::company_selection::description%]"
+ },
+ "station_selection": {
+ "data": {
+ "station": "[%key:component::fuelprices_dk::config::step::station_selection::data::station%]"
+ },
+ "data_description": {
+ "station": "[%key:component::fuelprices_dk::config::step::station_selection::data_description::station%]"
+ },
+ "description": "[%key:component::fuelprices_dk::config::step::station_selection::description%]"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/gatus/__init__.py b/homeassistant/components/gatus/__init__.py
new file mode 100644
index 000000000000..93cbcfc5999a
--- /dev/null
+++ b/homeassistant/components/gatus/__init__.py
@@ -0,0 +1,25 @@
+"""The Gatus integration."""
+
+from homeassistant.const import CONF_URL, Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator
+
+_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool:
+ """Set up Gatus from a config entry."""
+ coordinator = GatusDataUpdateCoordinator(hass, entry, entry.data[CONF_URL])
+
+ 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: GatusConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/gatus/binary_sensor.py b/homeassistant/components/gatus/binary_sensor.py
new file mode 100644
index 000000000000..f35d8815e42d
--- /dev/null
+++ b/homeassistant/components/gatus/binary_sensor.py
@@ -0,0 +1,105 @@
+"""Support for Gatus binary sensors."""
+
+from typing import override
+
+from gatus_api import EndpointStatus, Result
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: GatusConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Gatus binary sensor platform."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ GatusEndpointBinarySensor(coordinator, entry, endpoint_key)
+ for endpoint_key in coordinator.data
+ )
+
+
+class GatusEndpointBinarySensor(
+ CoordinatorEntity[GatusDataUpdateCoordinator], BinarySensorEntity
+):
+ """Representation of a Gatus endpoint status."""
+
+ _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
+ _attr_has_entity_name = True
+ _attr_name = None
+
+ def __init__(
+ self,
+ coordinator: GatusDataUpdateCoordinator,
+ entry: GatusConfigEntry,
+ endpoint_key: str,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self._endpoint_key = endpoint_key
+
+ endpoint_data = self.endpoint_data
+
+ endpoint_name = endpoint_data.name
+ if endpoint_data.group is not None:
+ device_name = f"{endpoint_data.group} {endpoint_name}"
+ else:
+ device_name = endpoint_name
+
+ self._attr_unique_id = f"{entry.entry_id}_{endpoint_key}"
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{entry.entry_id}_{endpoint_key}")},
+ name=device_name,
+ manufacturer="Gatus",
+ entry_type=DeviceEntryType.SERVICE,
+ )
+
+ @property
+ @override
+ def is_on(self) -> bool | None:
+ """Return true if the endpoint is up and healthy."""
+ latest_result = self.latest_result
+ if latest_result is None:
+ return None
+
+ return latest_result.success
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ data = self.coordinator.data
+ # Guard for empty results list, which could imply a brand new endpoint
+ return (
+ super().available
+ and self._endpoint_key in data
+ and bool(data[self._endpoint_key].results)
+ )
+
+ @property
+ def endpoint_data(self) -> EndpointStatus:
+ """Return this specific endpoint's data from the coordinator."""
+ return self.coordinator.data[self._endpoint_key]
+
+ @property
+ def latest_result(self) -> Result | None:
+ """Return the most recent monitoring result (Gatus appends newest last)."""
+ results = self.endpoint_data.results
+ if not results:
+ return None
+ return results[-1]
diff --git a/homeassistant/components/gatus/config_flow.py b/homeassistant/components/gatus/config_flow.py
new file mode 100644
index 000000000000..8abba8d95641
--- /dev/null
+++ b/homeassistant/components/gatus/config_flow.py
@@ -0,0 +1,119 @@
+"""Config flow for the Gatus integration."""
+
+import logging
+from typing import Any, override
+
+from gatus_api import GatusClient, GatusClientError
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+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_URL): str,
+ }
+)
+
+
+async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
+ """Validate that the user input allows us to connect to Gatus and return data."""
+ client = GatusClient(url=data[CONF_URL], session=async_get_clientsession(hass))
+
+ try:
+ await client.get_endpoints_statuses()
+ except GatusClientError as err:
+ _LOGGER.debug("Cannot connect to Gatus instance at %s: %s", data[CONF_URL], err)
+ raise CannotConnect from err
+
+
+class GatusConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Gatus."""
+
+ @override
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial setup step when adding the integration via the UI."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ user_input[CONF_URL] = str(
+ URL(user_input[CONF_URL])
+ .with_query(None)
+ .with_fragment(None)
+ .with_user(None)
+ .with_password(None)
+ ).rstrip("/")
+
+ self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
+
+ try:
+ await validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception during Gatus setup")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(title="Gatus", data=user_input)
+
+ 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 reconfiguration of an existing entry."""
+ errors: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+
+ if user_input is not None:
+ url = URL(user_input[CONF_URL])
+ user_input[CONF_URL] = str(
+ url.with_query(None)
+ .with_fragment(None)
+ .with_user(None)
+ .with_password(None)
+ ).rstrip("/")
+
+ if user_input[CONF_URL] != reconfigure_entry.data[CONF_URL]:
+ self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
+
+ try:
+ await validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception during Gatus reconfigure")
+ errors["base"] = "unknown"
+ else:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data_updates=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data
+ ),
+ errors=errors,
+ )
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect to the server."""
diff --git a/homeassistant/components/gatus/const.py b/homeassistant/components/gatus/const.py
new file mode 100644
index 000000000000..89ac9ee41fff
--- /dev/null
+++ b/homeassistant/components/gatus/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Gatus integration."""
+
+DOMAIN = "gatus"
diff --git a/homeassistant/components/gatus/coordinator.py b/homeassistant/components/gatus/coordinator.py
new file mode 100644
index 000000000000..37739f2ff6f3
--- /dev/null
+++ b/homeassistant/components/gatus/coordinator.py
@@ -0,0 +1,48 @@
+"""DataUpdateCoordinator for the Gatus integration."""
+
+from datetime import timedelta
+import logging
+from typing import override
+
+from gatus_api import EndpointStatus, GatusClient, GatusClientError
+
+from homeassistant.config_entries import ConfigEntry
+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 GatusConfigEntry = ConfigEntry[GatusDataUpdateCoordinator]
+
+
+class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus]]):
+ """Class to manage fetching Gatus data from the API via third-party library."""
+
+ def __init__(self, hass: HomeAssistant, entry: GatusConfigEntry, url: str) -> None:
+ """Initialize the coordinator."""
+ self.url = url.rstrip("/")
+ self.client = GatusClient(url=self.url, session=async_get_clientsession(hass))
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=DOMAIN,
+ update_interval=timedelta(seconds=30),
+ )
+
+ @override
+ async def _async_update_data(self) -> dict[str, EndpointStatus]:
+ """Fetch endpoint statuses from the Gatus API."""
+ try:
+ raw_endpoints = await self.client.get_endpoints_statuses()
+ except GatusClientError as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ ) from err
+
+ return {ep.key: ep for ep in raw_endpoints}
diff --git a/homeassistant/components/gatus/manifest.json b/homeassistant/components/gatus/manifest.json
new file mode 100644
index 000000000000..53fddeab56ed
--- /dev/null
+++ b/homeassistant/components/gatus/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "gatus",
+ "name": "Gatus",
+ "codeowners": ["@TN-1"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/gatus",
+ "integration_type": "service",
+ "iot_class": "local_polling",
+ "loggers": ["gatus_api"],
+ "quality_scale": "silver",
+ "requirements": ["gatus-api==1.0.3"]
+}
diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml
new file mode 100644
index 000000000000..dab6799b0a88
--- /dev/null
+++ b/homeassistant/components/gatus/quality_scale.yaml
@@ -0,0 +1,88 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register custom 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 does not register custom actions.
+ docs-conditions:
+ status: exempt
+ comment: Integration does not register custom conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: Integration does not register custom triggers.
+ entity-event-setup:
+ status: exempt
+ comment: Integration does not register custom 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: Integration does not register custom actions.
+ 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:
+ status: exempt
+ comment: Integration does not use authentication.
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: Integration does not support discovery.
+ discovery:
+ status: exempt
+ comment: Integration does not support discovery.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: All entities represent monitored services and should be enabled by default.
+ entity-translations:
+ status: exempt
+ comment: Entity names are dynamically provided by the Gatus service.
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: Entities use the connectivity device class for their icon and define no custom icons.
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: Integration does not require user intervention repairs.
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json
new file mode 100644
index 000000000000..413dc7180c91
--- /dev/null
+++ b/homeassistant/components/gatus/strings.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "step": {
+ "reconfigure": {
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]"
+ },
+ "data_description": {
+ "url": "[%key:component::gatus::config::step::user::data_description::url%]"
+ },
+ "description": "[%key:component::gatus::config::step::user::description%]"
+ },
+ "user": {
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]"
+ },
+ "data_description": {
+ "url": "The full base URL of your Gatus status page instance including protocol and port."
+ },
+ "description": "Enter the network details for your Gatus status page instance. Make sure to include the protocol (e.g., `http://` or `https://`) and the port number if you are not using a standard port."
+ }
+ }
+ },
+ "exceptions": {
+ "update_failed": {
+ "message": "Error communicating with Gatus API"
+ }
+ }
+}
diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json
index 33463d54dbd0..ec69e74bc215 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==17.0.1", "Pillow==12.2.0"]
+ "requirements": ["av==17.0.1", "Pillow==12.3.0"]
}
diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py
index 9af17b89c1ce..9540869b2765 100644
--- a/homeassistant/components/generic_hygrostat/__init__.py
+++ b/homeassistant/components/generic_hygrostat/__init__.py
@@ -105,7 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# humidifier's device.
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py
index 3d4781d43ed9..ef9acaaac724 100644
--- a/homeassistant/components/generic_hygrostat/humidifier.py
+++ b/homeassistant/components/generic_hygrostat/humidifier.py
@@ -7,7 +7,6 @@ import logging
from typing import TYPE_CHECKING, Any, cast, override
from homeassistant.components.humidifier import (
- ATTR_HUMIDITY,
MODE_AWAY,
MODE_NORMAL,
PLATFORM_SCHEMA as HUMIDIFIER_PLATFORM_SCHEMA,
@@ -15,11 +14,11 @@ from homeassistant.components.humidifier import (
HumidifierDeviceClass,
HumidifierEntity,
HumidifierEntityFeature,
+ HumidifierEntityStateAttribute,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
- ATTR_MODE,
CONF_DEVICE_CLASS,
CONF_NAME,
CONF_UNIQUE_ID,
@@ -266,12 +265,17 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup)
if (old_state := await self.async_get_last_state()) is not None:
- if old_state.attributes.get(ATTR_MODE) == MODE_AWAY:
+ if (
+ old_state.attributes.get(HumidifierEntityStateAttribute.MODE)
+ == MODE_AWAY
+ ):
self._is_away = True
self._saved_target_humidity = self._target_humidity
self._target_humidity = self._away_humidity or self._target_humidity
- if old_state.attributes.get(ATTR_HUMIDITY):
- self._target_humidity = int(old_state.attributes[ATTR_HUMIDITY])
+ if old_state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY):
+ self._target_humidity = int(
+ old_state.attributes[HumidifierEntityStateAttribute.HUMIDITY]
+ )
if old_state.attributes.get(ATTR_SAVED_HUMIDITY):
self._saved_target_humidity = int(
old_state.attributes[ATTR_SAVED_HUMIDITY]
diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py
index e2e997b9c11b..75f552b2850a 100644
--- a/homeassistant/components/generic_thermostat/__init__.py
+++ b/homeassistant/components/generic_thermostat/__init__.py
@@ -33,7 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# heater's device.
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
index 3828ea00945e..31491b3306c2 100644
--- a/homeassistant/components/generic_thermostat/climate.py
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -13,11 +13,11 @@ import voluptuous as vol
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
- ATTR_PRESET_MODE,
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
+ ClimateEntityStateAttribute,
HVACAction,
HVACMode,
)
@@ -351,7 +351,10 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
# If we have no initial temperature, restore
if self._target_temp is None:
# If we have a previously saved temperature
- if old_state.attributes.get(ATTR_TEMPERATURE) is None:
+ if (
+ old_state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE)
+ is None
+ ):
if self.ac_mode:
self._target_temp = self.max_temp
else:
@@ -361,12 +364,17 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self._target_temp,
)
else:
- self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE])
+ self._target_temp = float(
+ old_state.attributes[ClimateEntityStateAttribute.TEMPERATURE]
+ )
if (
self.preset_modes
- and old_state.attributes.get(ATTR_PRESET_MODE) in self.preset_modes
+ and old_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE)
+ in self.preset_modes
):
- self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE)
+ self._attr_preset_mode = old_state.attributes.get(
+ ClimateEntityStateAttribute.PRESET_MODE
+ )
if not self._hvac_mode and old_state.state:
self._hvac_mode = HVACMode(old_state.state)
diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py
index f9d6972f9c25..e3ce4bd21438 100644
--- a/homeassistant/components/geniushub/__init__.py
+++ b/homeassistant/components/geniushub/__init__.py
@@ -5,12 +5,9 @@ import logging
import aiohttp
from geniushubclient import GeniusHub
-import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_ENTITY_ID,
- ATTR_TEMPERATURE,
CONF_HOST,
CONF_MAC,
CONF_PASSWORD,
@@ -18,14 +15,15 @@ from homeassistant.const import (
CONF_USERNAME,
Platform,
)
-from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry as er
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.service import verify_domain_control
+from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
+from .services import setup_service_functions
_LOGGER = logging.getLogger(__name__)
@@ -34,31 +32,6 @@ SCAN_INTERVAL = timedelta(seconds=60)
MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$"
-ATTR_ZONE_MODE = "mode"
-ATTR_DURATION = "duration"
-
-SVC_SET_ZONE_MODE = "set_zone_mode"
-SVC_SET_ZONE_OVERRIDE = "set_zone_override"
-
-SET_ZONE_MODE_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_ENTITY_ID): cv.entity_id,
- vol.Required(ATTR_ZONE_MODE): vol.In(["off", "timer", "footprint"]),
- }
-)
-SET_ZONE_OVERRIDE_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_ENTITY_ID): cv.entity_id,
- vol.Required(ATTR_TEMPERATURE): vol.All(
- vol.Coerce(float), vol.Range(min=4, max=28)
- ),
- vol.Optional(ATTR_DURATION): vol.All(
- cv.time_period,
- vol.Range(min=timedelta(minutes=5), max=timedelta(days=1)),
- ),
- }
-)
-
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
@@ -70,6 +43,14 @@ PLATFORMS = [
type GeniusHubConfigEntry = ConfigEntry[GeniusBroker]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Genius Hub services."""
+ setup_service_functions(hass)
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> bool:
"""Create a Genius Hub system."""
@@ -111,49 +92,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) ->
async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL)
- setup_service_functions(hass, broker)
-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-@callback
-def setup_service_functions(hass: HomeAssistant, broker):
- """Set up the service functions."""
-
- @verify_domain_control(DOMAIN)
- async def set_zone_mode(call: ServiceCall) -> None:
- """Set the system mode."""
- entity_id = call.data[ATTR_ENTITY_ID]
-
- registry = er.async_get(hass)
- registry_entry = registry.async_get(entity_id)
-
- if registry_entry is None or registry_entry.platform != DOMAIN:
- raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity")
-
- if registry_entry.domain != "climate":
- raise ValueError(f"'{entity_id}' is not an {DOMAIN} zone")
-
- payload = {
- "unique_id": registry_entry.unique_id,
- "service": call.service,
- "data": call.data,
- }
-
- async_dispatcher_send(hass, DOMAIN, payload)
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, SVC_SET_ZONE_MODE, set_zone_mode, schema=SET_ZONE_MODE_SCHEMA
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, SVC_SET_ZONE_OVERRIDE, set_zone_mode, schema=SET_ZONE_OVERRIDE_SCHEMA
- )
-
-
class GeniusBroker:
"""Container for geniushub client and data."""
diff --git a/homeassistant/components/geniushub/const.py b/homeassistant/components/geniushub/const.py
index 4601eca5f9bd..e62caa15fd45 100644
--- a/homeassistant/components/geniushub/const.py
+++ b/homeassistant/components/geniushub/const.py
@@ -6,6 +6,12 @@ from homeassistant.const import Platform
DOMAIN = "geniushub"
+ATTR_ZONE_MODE = "mode"
+ATTR_DURATION = "duration"
+
+SVC_SET_ZONE_MODE = "set_zone_mode"
+SVC_SET_ZONE_OVERRIDE = "set_zone_override"
+
SCAN_INTERVAL = timedelta(seconds=60)
SENSOR_PREFIX = "Genius"
diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py
index 741adac6c014..c9d7085b14c8 100644
--- a/homeassistant/components/geniushub/entity.py
+++ b/homeassistant/components/geniushub/entity.py
@@ -8,7 +8,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.util import dt as dt_util
-from . import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE
+from .const import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE
# temperature is repeated here, as it gives access to high-precision temps
GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"]
diff --git a/homeassistant/components/geniushub/services.py b/homeassistant/components/geniushub/services.py
new file mode 100644
index 000000000000..ac22ee8c5283
--- /dev/null
+++ b/homeassistant/components/geniushub/services.py
@@ -0,0 +1,81 @@
+"""Support for Genius Hub services."""
+
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.service import verify_domain_control
+
+from .const import (
+ ATTR_DURATION,
+ ATTR_ZONE_MODE,
+ DOMAIN,
+ SVC_SET_ZONE_MODE,
+ SVC_SET_ZONE_OVERRIDE,
+)
+
+SET_ZONE_MODE_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_ZONE_MODE): vol.In(["off", "timer", "footprint"]),
+ }
+)
+SET_ZONE_OVERRIDE_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_TEMPERATURE): vol.All(
+ vol.Coerce(float), vol.Range(min=4, max=28)
+ ),
+ vol.Optional(ATTR_DURATION): vol.All(
+ cv.time_period,
+ vol.Range(min=timedelta(minutes=5), max=timedelta(days=1)),
+ ),
+ }
+)
+
+
+@callback
+def setup_service_functions(hass: HomeAssistant) -> None:
+ """Set up the service functions."""
+
+ @verify_domain_control(DOMAIN)
+ async def set_zone_mode(call: ServiceCall) -> None:
+ """Set the system mode."""
+ entity_id = call.data[ATTR_ENTITY_ID]
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(entity_id)
+
+ if registry_entry is None or registry_entry.platform != DOMAIN:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_entity_id",
+ translation_placeholders={"entity_id": entity_id},
+ )
+
+ if registry_entry.domain != "climate":
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_zone",
+ translation_placeholders={"entity_id": entity_id},
+ )
+
+ payload = {
+ "unique_id": registry_entry.unique_id,
+ "service": call.service,
+ "data": call.data,
+ }
+
+ async_dispatcher_send(hass, DOMAIN, payload)
+
+ hass.services.async_register(
+ DOMAIN, SVC_SET_ZONE_MODE, set_zone_mode, schema=SET_ZONE_MODE_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SVC_SET_ZONE_OVERRIDE, set_zone_mode, schema=SET_ZONE_OVERRIDE_SCHEMA
+ )
diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json
index 57521666ffb6..a90df19191fd 100644
--- a/homeassistant/components/geniushub/strings.json
+++ b/homeassistant/components/geniushub/strings.json
@@ -34,6 +34,14 @@
}
},
+ "exceptions": {
+ "invalid_entity_id": {
+ "message": "Unknown Genius Hub entity: {entity_id}"
+ },
+ "invalid_zone": {
+ "message": "Unknown Genius Hub zone: {entity_id}"
+ }
+ },
"services": {
"set_switch_override": {
"description": "Overrides switch for a given duration.",
diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py
index 0abb997612f5..d22ec6c6c833 100644
--- a/homeassistant/components/geniushub/switch.py
+++ b/homeassistant/components/geniushub/switch.py
@@ -11,7 +11,8 @@ from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
-from . import ATTR_DURATION, GeniusHubConfigEntry
+from . import GeniusHubConfigEntry
+from .const import ATTR_DURATION
from .entity import GeniusZone
GH_ON_OFF_ZONE = "on / off"
diff --git a/homeassistant/components/gentex_homelink/oauth2.py b/homeassistant/components/gentex_homelink/oauth2.py
index 894c15378605..027100b7446d 100644
--- a/homeassistant/components/gentex_homelink/oauth2.py
+++ b/homeassistant/components/gentex_homelink/oauth2.py
@@ -110,7 +110,6 @@ class AsyncConfigEntryAuth(AbstractAuth):
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
- if not self._oauth_session.valid_token:
- await self._oauth_session.async_ensure_token_valid()
+ await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token["access_token"]
diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py
index 743bc3ede71d..35a5f5c1a51a 100644
--- a/homeassistant/components/geo_location/__init__.py
+++ b/homeassistant/components/geo_location/__init__.py
@@ -7,7 +7,11 @@ from typing import Any, final, override
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE # noqa: F401
+from homeassistant.const import ( # noqa: F401
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ EntityStateAttribute,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -105,7 +109,7 @@ class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the state attributes of this external event."""
data: dict[str, Any] = {GeolocationEntityStateAttribute.SOURCE: self.source}
if self.latitude is not None:
- data[GeolocationEntityStateAttribute.LATITUDE] = round(self.latitude, 5)
+ data[EntityStateAttribute.LATITUDE] = round(self.latitude, 5)
if self.longitude is not None:
- data[GeolocationEntityStateAttribute.LONGITUDE] = round(self.longitude, 5)
+ data[EntityStateAttribute.LONGITUDE] = round(self.longitude, 5)
return data
diff --git a/homeassistant/components/geo_location/const.py b/homeassistant/components/geo_location/const.py
index 8cd7194e8551..6d1513ccb714 100644
--- a/homeassistant/components/geo_location/const.py
+++ b/homeassistant/components/geo_location/const.py
@@ -2,10 +2,19 @@
from enum import StrEnum
+from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers
-class GeolocationEntityStateAttribute(StrEnum):
+
+class GeolocationEntityStateAttribute(
+ StrEnum,
+ metaclass=EnumWithDeprecatedMembers,
+ deprecated={
+ "LATITUDE": ("EntityStateAttribute.LATITUDE", "2027.2.0"),
+ "LONGITUDE": ("EntityStateAttribute.LONGITUDE", "2027.2.0"),
+ },
+):
"""State attributes for geolocation entities."""
SOURCE = "source"
- LATITUDE = "latitude"
- LONGITUDE = "longitude"
+ LATITUDE = "latitude" # Deprecated, replaced with EntityStateAttribute.LATITUDE
+ LONGITUDE = "longitude" # Deprecated, replaced with EntityStateAttribute.LONGITUDE
diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py
index 788a5dffac79..8d7c3b24cc42 100644
--- a/homeassistant/components/geofency/device_tracker.py
+++ b/homeassistant/components/geofency/device_tracker.py
@@ -3,7 +3,7 @@
from typing import override
from homeassistant.components.device_tracker import TrackerEntity
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -92,8 +92,8 @@ class GeofencyEntity(TrackerEntity, RestoreEntity):
return
attr = state.attributes
- self._attr_latitude = attr.get(ATTR_LATITUDE)
- self._attr_longitude = attr.get(ATTR_LONGITUDE)
+ self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE)
+ self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE)
@override
async def async_will_remove_from_hass(self) -> None:
diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py
index 473a4ac0ad50..72f305d9f1e8 100644
--- a/homeassistant/components/geonetnz_volcano/sensor.py
+++ b/homeassistant/components/geonetnz_volcano/sensor.py
@@ -4,7 +4,7 @@ import logging
from typing import Any, override
from homeassistant.components.sensor import SensorEntity
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength
+from homeassistant.const import EntityStateAttribute, UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -147,8 +147,8 @@ class GeonetnzVolcanoSensor(SensorEntity):
(ATTR_EXTERNAL_ID, self._external_id),
(ATTR_ACTIVITY, self._activity),
(ATTR_HAZARDS, self._hazards),
- (ATTR_LONGITUDE, self._longitude),
- (ATTR_LATITUDE, self._latitude),
+ (EntityStateAttribute.LONGITUDE, self._longitude),
+ (EntityStateAttribute.LATITUDE, self._latitude),
(ATTR_DISTANCE, self._distance),
(ATTR_LAST_UPDATE, self._feed_last_update),
(ATTR_LAST_UPDATE_SUCCESSFUL, self._feed_last_update_successful),
diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py
index 4d2dc968eb9c..4b4bb22d6d9c 100644
--- a/homeassistant/components/github/__init__.py
+++ b/homeassistant/components/github/__init__.py
@@ -98,9 +98,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) ->
if device := dev_reg.async_get_device({(DOMAIN, repository)}):
dev_reg.async_update_device(
device.id,
- remove_config_entry_id=entry.entry_id,
- add_config_subentry_id=subentry.subentry_id,
- add_config_entry_id=entry.entry_id,
+ new_config_entry_id=entry.entry_id,
+ new_config_subentry_id=subentry.subentry_id,
)
hass.config_entries.async_update_entry(entry, minor_version=2)
return True
diff --git a/homeassistant/components/gitlab_ci/__init__.py b/homeassistant/components/gitlab_ci/__init__.py
index 93b2a08c714a..27d82026ab9d 100644
--- a/homeassistant/components/gitlab_ci/__init__.py
+++ b/homeassistant/components/gitlab_ci/__init__.py
@@ -1 +1 @@
-"""The gitlab_ci component."""
+"""The GitLab-CI integration."""
diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py
index 817467828325..3c87085929d2 100644
--- a/homeassistant/components/google_assistant/__init__.py
+++ b/homeassistant/components/google_assistant/__init__.py
@@ -1,13 +1,11 @@
"""Support for Actions on Google Assistant Smart Home Control."""
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
-import logging
-
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
@@ -29,15 +27,13 @@ from .const import ( # noqa: F401
DEFAULT_EXPOSED_DOMAINS,
DOMAIN,
EVENT_QUERY_RECEIVED,
- SERVICE_REQUEST_SYNC,
SOURCE_CLOUD,
)
from .http import GoogleAssistantView, GoogleConfig
+from .services import async_register_services
from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, isort:skip
-_LOGGER = logging.getLogger(__name__)
-
CONF_ALLOW_UNLOCK = "allow_unlock"
PLATFORMS = [Platform.BUTTON]
@@ -105,6 +101,9 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_CONFIG] = yaml_config[DOMAIN]
+ if CONF_SERVICE_ACCOUNT in yaml_config[DOMAIN]:
+ async_register_services(hass)
+
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
@@ -149,26 +148,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
if google_config.should_report_state:
google_config.async_enable_report_state()
- async def request_sync_service_handler(call: ServiceCall) -> None:
- """Handle request sync service calls."""
- agent_user_id = call.data.get("agent_user_id") or call.context.user_id
-
- if agent_user_id is None:
- _LOGGER.warning(
- "No agent_user_id supplied for request_sync. Call as a user or pass in"
- " user id as agent_user_id"
- )
- return
-
- await google_config.async_sync_entities(agent_user_id)
-
- # Register service only if key is provided
- if CONF_SERVICE_ACCOUNT in config:
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler
- )
-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/google_assistant/services.py b/homeassistant/components/google_assistant/services.py
new file mode 100644
index 000000000000..2c4391558e5d
--- /dev/null
+++ b/homeassistant/components/google_assistant/services.py
@@ -0,0 +1,37 @@
+"""Support for Google Assistant services."""
+
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.helpers import service
+
+from .const import DOMAIN, SERVICE_REQUEST_SYNC
+from .http import GoogleConfig
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_register_services(hass: HomeAssistant) -> None:
+ """Register Google Assistant services."""
+
+ async def request_sync_service_handler(call: ServiceCall) -> None:
+ """Handle request sync service calls."""
+ agent_user_id = call.data.get("agent_user_id") or call.context.user_id
+
+ if agent_user_id is None:
+ _LOGGER.warning(
+ "No agent_user_id supplied for request_sync. Call as a user or pass in"
+ " user id as agent_user_id"
+ )
+ return
+
+ entry: ConfigEntry[GoogleConfig] = service.async_get_config_entry(
+ hass, DOMAIN, None
+ )
+ await entry.runtime_data.async_sync_entities(agent_user_id)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler
+ )
diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py
index b3f0eb0ce829..2667a278c9c1 100644
--- a/homeassistant/components/google_generative_ai_conversation/__init__.py
+++ b/homeassistant/components/google_generative_ai_conversation/__init__.py
@@ -20,7 +20,7 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import (
DEFAULT_AI_TASK_NAME,
@@ -182,7 +182,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
# Device and entity registries will set the disabled_by flag to None
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
# config entry, but we want to set it to USER instead,
- device_disabled_by = device.disabled_by
+ device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
and not all_disabled
@@ -192,20 +192,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
disabled_by=device_disabled_by,
new_identifiers={(DOMAIN, subentry.subentry_id)},
- add_config_subentry_id=subentry.subentry_id,
- add_config_entry_id=parent_entry.entry_id,
+ new_config_entry_id=parent_entry.entry_id,
+ new_config_subentry_id=subentry.subentry_id,
)
- if parent_entry.entry_id != entry.entry_id:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- )
- else:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- remove_config_subentry_id=None,
- )
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
diff --git a/homeassistant/components/google_health/__init__.py b/homeassistant/components/google_health/__init__.py
new file mode 100644
index 000000000000..a389edc4bfa3
--- /dev/null
+++ b/homeassistant/components/google_health/__init__.py
@@ -0,0 +1,88 @@
+"""The Google Health integration."""
+
+from dataclasses import dataclass
+
+from google_health_api import GoogleHealthApi
+from google_health_api.const import HealthApiScope
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.config_entry_oauth2_flow import (
+ ImplementationUnavailableError,
+ OAuth2Session,
+ async_get_config_entry_implementation,
+)
+
+from . import api
+from .const import DOMAIN
+from .coordinator import GoogleHealthActivityCoordinator, GoogleHealthBodyCoordinator
+
+_PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+
+@dataclass
+class GoogleHealthData:
+ """Class to hold Google Health coordinators."""
+
+ activity_coordinator: GoogleHealthActivityCoordinator | None = None
+ body_coordinator: GoogleHealthBodyCoordinator | None = None
+
+
+type GoogleHealthConfigEntry = ConfigEntry[GoogleHealthData]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: GoogleHealthConfigEntry
+) -> bool:
+ """Set up Google Health from a config entry."""
+ try:
+ implementation = await async_get_config_entry_implementation(hass, entry)
+ except ImplementationUnavailableError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="oauth_error",
+ ) from err
+
+ session = OAuth2Session(hass, entry, implementation)
+
+ scopes = session.token.get("scope", "").split()
+ if HealthApiScope.PROFILE_READ not in scopes:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="missing_profile_scope",
+ )
+
+ auth = api.AsyncConfigEntryAuth(
+ aiohttp_client.async_get_clientsession(hass), session
+ )
+
+ api_client = GoogleHealthApi(auth)
+
+ activity_coordinator = None
+ if all(scope in scopes for scope in api_client.steps.required_read_scopes):
+ activity_coordinator = GoogleHealthActivityCoordinator(hass, entry, api_client)
+ await activity_coordinator.async_config_entry_first_refresh()
+
+ body_coordinator = None
+ if all(scope in scopes for scope in api_client.weight.required_read_scopes):
+ body_coordinator = GoogleHealthBodyCoordinator(hass, entry, api_client)
+ await body_coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = GoogleHealthData(
+ activity_coordinator=activity_coordinator,
+ body_coordinator=body_coordinator,
+ )
+
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: GoogleHealthConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/google_health/api.py b/homeassistant/components/google_health/api.py
new file mode 100644
index 000000000000..02c3bcfd4b4a
--- /dev/null
+++ b/homeassistant/components/google_health/api.py
@@ -0,0 +1,42 @@
+"""API for Google Health bound to Home Assistant OAuth."""
+
+from typing import cast, override
+
+from aiohttp import ClientSession
+from google_health_api.auth import AbstractAuth
+
+from homeassistant.helpers import config_entry_oauth2_flow
+
+
+class AsyncConfigEntryAuth(AbstractAuth):
+ """Provide Google Health authentication tied to an OAuth2 based config entry."""
+
+ def __init__(
+ self,
+ websession: ClientSession,
+ oauth_session: config_entry_oauth2_flow.OAuth2Session,
+ ) -> None:
+ """Initialize Google Health auth."""
+ super().__init__(websession)
+ self._oauth_session = oauth_session
+
+ @override
+ 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"])
+
+
+class SimpleAuth(AbstractAuth):
+ """Temporary auth helper for the config flow."""
+
+ def __init__(self, websession: ClientSession, access_token: str) -> None:
+ """Initialize the auth helper."""
+ super().__init__(websession)
+ self._access_token = access_token
+
+ @override
+ async def async_get_access_token(self) -> str:
+ """Return the access token."""
+ return self._access_token
diff --git a/homeassistant/components/google_health/application_credentials.py b/homeassistant/components/google_health/application_credentials.py
new file mode 100644
index 000000000000..732a13fece13
--- /dev/null
+++ b/homeassistant/components/google_health/application_credentials.py
@@ -0,0 +1,23 @@
+"""Application credentials platform for the Google Health integration."""
+
+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,
+ )
+
+
+async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
+ """Return description placeholders for the credentials dialog."""
+ return {
+ "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
+ "more_info_url": "https://www.home-assistant.io/integrations/google_health/",
+ "oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
+ }
diff --git a/homeassistant/components/google_health/config_flow.py b/homeassistant/components/google_health/config_flow.py
new file mode 100644
index 000000000000..4450e44e6fe7
--- /dev/null
+++ b/homeassistant/components/google_health/config_flow.py
@@ -0,0 +1,105 @@
+"""Config flow for Google Health."""
+
+from collections.abc import Mapping
+import logging
+from typing import Any, override
+
+from google_health_api import GoogleHealthApi
+from google_health_api.const import HealthApiScope
+from google_health_api.exceptions import (
+ GoogleHealthApiError,
+ HealthApiForbiddenException,
+)
+
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
+from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
+
+from .api import SimpleAuth
+from .const import API_CONSOLE_URL, DEFAULT_TITLE, DOMAIN, OAUTH_SCOPES
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class OAuth2FlowHandler(
+ config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
+):
+ """Config flow to handle Google Health OAuth2 authentication."""
+
+ DOMAIN = DOMAIN
+
+ @property
+ @override
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return _LOGGER
+
+ @property
+ @override
+ def extra_authorize_data(self) -> dict[str, Any]:
+ """Extra data that needs to be appended to the authorize url."""
+ return {
+ "scope": " ".join(OAUTH_SCOPES),
+ "access_type": "offline",
+ "prompt": "consent",
+ }
+
+ 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."""
+ if user_input is None:
+ return self.async_show_form(step_id="reauth_confirm")
+ return await self.async_step_user()
+
+ @override
+ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
+ scopes = data.get(CONF_TOKEN, {}).get("scope", "").split()
+ if HealthApiScope.PROFILE_READ not in scopes:
+ return self.async_abort(reason="missing_profile_scope")
+
+ access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
+ websession = aiohttp_client.async_get_clientsession(self.hass)
+ auth = SimpleAuth(websession, access_token)
+ api = GoogleHealthApi(auth)
+
+ try:
+ identity = await api.get_identity()
+ except HealthApiForbiddenException as err:
+ _LOGGER.error("Error getting Google Health identity: %s", err)
+ return self.async_abort(
+ reason="api_not_enabled",
+ description_placeholders={"url": API_CONSOLE_URL},
+ )
+ except GoogleHealthApiError as err:
+ _LOGGER.error("Error getting Google Health identity: %s", err)
+ return self.async_abort(reason="cannot_connect")
+
+ if not identity.health_user_id:
+ _LOGGER.error("Google Health identity has no health_user_id")
+ return self.async_abort(reason="cannot_connect")
+
+ await self.async_set_unique_id(identity.health_user_id)
+ if self.source == SOURCE_REAUTH:
+ reauth_entry = self._get_reauth_entry()
+ return self.async_update_reload_and_abort(reauth_entry, data=data)
+ self._abort_if_unique_id_configured()
+
+ display_name = None
+ if HealthApiScope.USERINFO_PROFILE in scopes or "profile" in scopes:
+ try:
+ userinfo = await api.get_user_info()
+ display_name = userinfo.given_name or userinfo.name
+ except Exception as err: # pylint: disable=broad-except # noqa: BLE001
+ _LOGGER.warning("Error fetching user profile name: %s", err)
+
+ return self.async_create_entry(
+ title=display_name or DEFAULT_TITLE,
+ data=data,
+ )
diff --git a/homeassistant/components/google_health/const.py b/homeassistant/components/google_health/const.py
new file mode 100644
index 000000000000..fc4660cc0695
--- /dev/null
+++ b/homeassistant/components/google_health/const.py
@@ -0,0 +1,21 @@
+"""Constants for the Google Health integration."""
+
+from google_health_api.const import HealthApiScope
+
+DOMAIN = "google_health"
+
+OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth"
+OAUTH2_TOKEN = "https://oauth2.googleapis.com/token"
+
+API_CONSOLE_URL = (
+ "https://console.developers.google.com/apis/api/health.googleapis.com/overview"
+)
+
+DEFAULT_TITLE = "Google Health"
+
+OAUTH_SCOPES = [
+ HealthApiScope.ACTIVITY_READ,
+ HealthApiScope.PROFILE_READ,
+ HealthApiScope.MEASUREMENTS_READ,
+ HealthApiScope.USERINFO_PROFILE,
+]
diff --git a/homeassistant/components/google_health/coordinator.py b/homeassistant/components/google_health/coordinator.py
new file mode 100644
index 000000000000..a9b515b998aa
--- /dev/null
+++ b/homeassistant/components/google_health/coordinator.py
@@ -0,0 +1,213 @@
+"""Coordinators for Google Health."""
+
+import asyncio
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+from typing import TYPE_CHECKING, override
+
+from google_health_api import GoogleHealthApi
+from google_health_api.exceptions import (
+ GoogleHealthApiError,
+ HealthApiForbiddenException,
+ HealthAuthException,
+)
+from google_health_api.model import (
+ ActiveEnergyBurnedRollupValue,
+ BodyFat,
+ DailyRestingHeartRate,
+ DistanceRollupValue,
+ FloorsRollupValue,
+ StepsRollupValue,
+ TotalCaloriesRollupValue,
+ Weight,
+)
+
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+if TYPE_CHECKING:
+ from . import GoogleHealthConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+POLLING_INTERVAL = timedelta(minutes=15)
+BODY_POLLING_INTERVAL = timedelta(hours=1)
+DEFAULT_PAGE_SIZE = 1
+
+
+@dataclass
+class GoogleHealthActivityData:
+ """Class to hold activity data."""
+
+ steps: StepsRollupValue | None = None
+ distance: DistanceRollupValue | None = None
+ active_energy_burned: ActiveEnergyBurnedRollupValue | None = None
+ total_calories: TotalCaloriesRollupValue | None = None
+ floors: FloorsRollupValue | None = None
+
+
+@dataclass
+class GoogleHealthBodyData:
+ """Class to hold body measurements."""
+
+ weight: Weight | None = None
+ resting_heart_rate: DailyRestingHeartRate | None = None
+ body_fat: BodyFat | None = None
+
+
+class GoogleHealthDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
+ """Base coordinator for Google Health API."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ logger: logging.Logger,
+ name: str,
+ update_interval: timedelta,
+ entry: GoogleHealthConfigEntry,
+ api_client: GoogleHealthApi,
+ ) -> None:
+ """Initialize the coordinator."""
+ self.api = api_client
+ super().__init__(
+ hass,
+ logger,
+ name=name,
+ update_interval=update_interval,
+ config_entry=entry,
+ )
+
+ @override
+ async def _async_update_data(self) -> _DataT:
+ """Fetch data from API."""
+ try:
+ return await self._async_fetch_data()
+ except (HealthAuthException, HealthApiForbiddenException) as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ ) from err
+ except GoogleHealthApiError as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="communication_error",
+ ) from err
+
+ async def _async_fetch_data(self) -> _DataT:
+ """Fetch data from API."""
+ raise NotImplementedError
+
+
+class GoogleHealthActivityCoordinator(
+ GoogleHealthDataUpdateCoordinator[GoogleHealthActivityData]
+):
+ """Coordinator to fetch activity data from Google Health API."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: GoogleHealthConfigEntry,
+ api_client: GoogleHealthApi,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=f"{DOMAIN}_activity",
+ update_interval=POLLING_INTERVAL,
+ entry=entry,
+ api_client=api_client,
+ )
+
+ @override
+ async def _async_fetch_data(self) -> GoogleHealthActivityData:
+ """Fetch activity rollups for today.
+
+ Queries the daily rollup endpoints in parallel using Home Assistant's
+ local time zone to aggregate steps, distance, active calories, total
+ calories, and floors. If no data points exist for today yet, the API
+ returns None, which the sensors default to 0.
+ """
+ (
+ steps_rollup,
+ distance_rollup,
+ active_energy_rollup,
+ total_calories_rollup,
+ floors_rollup,
+ ) = await asyncio.gather(
+ self.api.steps.today(self.hass.config.time_zone),
+ self.api.distance.today(self.hass.config.time_zone),
+ self.api.active_energy_burned.today(self.hass.config.time_zone),
+ self.api.total_calories.today(self.hass.config.time_zone),
+ self.api.floors.today(self.hass.config.time_zone),
+ )
+
+ steps = steps_rollup.data if steps_rollup else None
+ distance = distance_rollup.data if distance_rollup else None
+ active_energy_burned = (
+ active_energy_rollup.data if active_energy_rollup else None
+ )
+ total_calories = total_calories_rollup.data if total_calories_rollup else None
+ floors = floors_rollup.data if floors_rollup else None
+
+ return GoogleHealthActivityData(
+ steps=steps,
+ distance=distance,
+ active_energy_burned=active_energy_burned,
+ total_calories=total_calories,
+ floors=floors,
+ )
+
+
+class GoogleHealthBodyCoordinator(
+ GoogleHealthDataUpdateCoordinator[GoogleHealthBodyData]
+):
+ """Coordinator to fetch body measurements from Google Health API."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: GoogleHealthConfigEntry,
+ api_client: GoogleHealthApi,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=f"{DOMAIN}_body",
+ update_interval=BODY_POLLING_INTERVAL,
+ entry=entry,
+ api_client=api_client,
+ )
+
+ @override
+ async def _async_fetch_data(self) -> GoogleHealthBodyData:
+ """Fetch latest body weight, resting heart rate, and body fat in parallel."""
+ # The Google Health API returns data points sorted by interval start time
+ # in descending order (newest first). Querying with page_size=1 and grabbing
+ # the first element is sufficient to fetch the most recent measurement.
+ weight_result, hr_result, body_fat_result = await asyncio.gather(
+ self.api.weight.list(page_size=DEFAULT_PAGE_SIZE),
+ self.api.daily_resting_heart_rate.list(page_size=DEFAULT_PAGE_SIZE),
+ self.api.body_fat.list(page_size=DEFAULT_PAGE_SIZE),
+ )
+
+ weight = (
+ weight_result.data_points[0].data if weight_result.data_points else None
+ )
+ resting_heart_rate = (
+ hr_result.data_points[0].data if hr_result.data_points else None
+ )
+ body_fat = (
+ body_fat_result.data_points[0].data if body_fat_result.data_points else None
+ )
+
+ return GoogleHealthBodyData(
+ weight=weight,
+ resting_heart_rate=resting_heart_rate,
+ body_fat=body_fat,
+ )
diff --git a/homeassistant/components/google_health/icons.json b/homeassistant/components/google_health/icons.json
new file mode 100644
index 000000000000..2c15524eb0f2
--- /dev/null
+++ b/homeassistant/components/google_health/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "sensor": {
+ "resting_heart_rate": {
+ "default": "mdi:heart-pulse"
+ },
+ "steps": {
+ "default": "mdi:walk"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/google_health/manifest.json b/homeassistant/components/google_health/manifest.json
new file mode 100644
index 000000000000..52ce63008c72
--- /dev/null
+++ b/homeassistant/components/google_health/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "google_health",
+ "name": "Google Health",
+ "codeowners": ["@allenporter"],
+ "config_flow": true,
+ "dependencies": ["application_credentials", "http", "webhook"],
+ "documentation": "https://www.home-assistant.io/integrations/google_health",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "quality_scale": "bronze",
+ "requirements": ["google-health-api==0.5.1"]
+}
diff --git a/homeassistant/components/google_health/quality_scale.yaml b/homeassistant/components/google_health/quality_scale.yaml
new file mode 100644
index 000000000000..adec2cbaf41d
--- /dev/null
+++ b/homeassistant/components/google_health/quality_scale.yaml
@@ -0,0 +1,88 @@
+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-conditions:
+ status: exempt
+ comment: This integration does not have custom conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: This integration does not have custom triggers.
+ entity-event-setup:
+ status: exempt
+ comment: This integration does not 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:
+ status: exempt
+ comment: This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No configuration parameters are available.
+ 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: todo
+ discovery-update-info:
+ status: exempt
+ comment: This integration does not support discovery.
+ discovery:
+ status: exempt
+ comment: This integration does not support discovery.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category:
+ status: exempt
+ comment: All entities are user-facing primary sensors.
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: No entities need to be disabled by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: This integration does not raise repair issues.
+ stale-devices:
+ status: exempt
+ comment: This integration has a static device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/google_health/sensor.py b/homeassistant/components/google_health/sensor.py
new file mode 100644
index 000000000000..004f84bce3de
--- /dev/null
+++ b/homeassistant/components/google_health/sensor.py
@@ -0,0 +1,173 @@
+"""Sensor platform for the Google Health integration."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any, cast, override
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfMass
+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 . import GoogleHealthConfigEntry
+from .const import DOMAIN
+from .coordinator import (
+ GoogleHealthActivityCoordinator,
+ GoogleHealthBodyCoordinator,
+ GoogleHealthDataUpdateCoordinator,
+)
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class GoogleHealthSensorEntityDescription[
+ _CoordinatorT: GoogleHealthDataUpdateCoordinator[Any],
+ _ValueT: StateType,
+](SensorEntityDescription):
+ """Class describing Google Health sensor entities."""
+
+ value_fn: Callable[[Any], _ValueT]
+
+
+ACTIVITY_SENSORS: list[
+ GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, Any]
+] = [
+ GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int](
+ key="steps",
+ translation_key="steps",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda data: data.steps.count_sum if data and data.steps else 0,
+ ),
+ GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
+ key="distance",
+ native_unit_of_measurement=UnitOfLength.METERS,
+ device_class=SensorDeviceClass.DISTANCE,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda data: (
+ data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0
+ ),
+ ),
+ GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
+ key="active_calories",
+ translation_key="active_calories",
+ native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda data: (
+ data.active_energy_burned.kcal_sum
+ if data and data.active_energy_burned
+ else 0.0
+ ),
+ ),
+ GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
+ key="total_calories",
+ translation_key="total_calories",
+ native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda data: (
+ data.total_calories.kcal_sum if data and data.total_calories else 0.0
+ ),
+ ),
+ GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int](
+ key="floors",
+ translation_key="floors",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda data: data.floors.count_sum if data and data.floors else 0,
+ ),
+]
+
+BODY_SENSORS: list[
+ GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, Any]
+] = [
+ GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None](
+ key="weight",
+ native_unit_of_measurement=UnitOfMass.KILOGRAMS,
+ device_class=SensorDeviceClass.WEIGHT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda data: (
+ data.weight.weight_grams / 1000.0 if data and data.weight else None
+ ),
+ ),
+ GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, int | None](
+ key="resting_heart_rate",
+ translation_key="resting_heart_rate",
+ native_unit_of_measurement="bpm",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda data: (
+ data.resting_heart_rate.beats_per_minute
+ if data and data.resting_heart_rate
+ else None
+ ),
+ ),
+ GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None](
+ key="body_fat",
+ translation_key="body_fat",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda data: (
+ data.body_fat.percentage if data and data.body_fat else None
+ ),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: GoogleHealthConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Google Health sensor platform."""
+ data = entry.runtime_data
+
+ entities: list[SensorEntity] = []
+ if (activity_coordinator := data.activity_coordinator) is not None:
+ entities.extend(
+ GoogleHealthSensor(activity_coordinator, entry.entry_id, description)
+ for description in ACTIVITY_SENSORS
+ )
+ if (body_coordinator := data.body_coordinator) is not None:
+ entities.extend(
+ GoogleHealthSensor(body_coordinator, entry.entry_id, description)
+ for description in BODY_SENSORS
+ )
+
+ if entities:
+ async_add_entities(entities)
+
+
+class GoogleHealthSensor[_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any]](
+ CoordinatorEntity[_CoordinatorT], SensorEntity
+):
+ """Generic Google Health sensor entity."""
+
+ _attr_has_entity_name = True
+ entity_description: GoogleHealthSensorEntityDescription[_CoordinatorT, Any]
+
+ def __init__(
+ self,
+ coordinator: _CoordinatorT,
+ entry_id: str,
+ description: GoogleHealthSensorEntityDescription[_CoordinatorT, Any],
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{entry_id}_{description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, entry_id)},
+ manufacturer="Google",
+ )
+
+ @property
+ @override
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return cast(StateType, self.entity_description.value_fn(self.coordinator.data))
diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json
new file mode 100644
index 000000000000..3263e03978f1
--- /dev/null
+++ b/homeassistant/components/google_health/strings.json
@@ -0,0 +1,73 @@
+{
+ "application_credentials": {
+ "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Health data. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type."
+ },
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "api_not_enabled": "The Google Health API is not enabled for your Google Cloud project. Enable it in the [Google Cloud Console]({url}), wait a few minutes for the change to propagate, then try again.",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "cannot_connect": "Failed to connect.",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "missing_profile_scope": "Missing required Google Health profile read permission.",
+ "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_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]",
+ "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%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
+ },
+ "create_entry": {
+ "default": "[%key:common::config_flow::create_entry::authenticated%]"
+ },
+ "step": {
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "reauth_confirm": {
+ "description": "The Google Health integration needs to re-authenticate your account",
+ "title": "[%key:common::config_flow::title::reauth%]"
+ }
+ }
+ },
+ "entity": {
+ "sensor": {
+ "active_calories": {
+ "name": "Active calories"
+ },
+ "body_fat": {
+ "name": "Body fat"
+ },
+ "floors": {
+ "name": "Floors"
+ },
+ "resting_heart_rate": {
+ "name": "Resting heart rate"
+ },
+ "steps": {
+ "name": "Steps",
+ "unit_of_measurement": "steps"
+ },
+ "total_calories": {
+ "name": "Total calories"
+ }
+ }
+ },
+ "exceptions": {
+ "auth_error": {
+ "message": "Authentication or permission error talking to Google Health."
+ },
+ "communication_error": {
+ "message": "Error communicating with Google Health."
+ },
+ "missing_profile_scope": {
+ "message": "Missing required Google Health profile read permission."
+ },
+ "oauth_error": {
+ "message": "OAuth2 implementation temporarily unavailable."
+ }
+ }
+}
diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py
index 3700e0fb890d..f1d46178ccc8 100644
--- a/homeassistant/components/google_mail/__init__.py
+++ b/homeassistant/components/google_mail/__init__.py
@@ -1,4 +1,5 @@
"""Support for Google Mail."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py
index c8dd60ba98f2..32e591e099cd 100644
--- a/homeassistant/components/gpslogger/device_tracker.py
+++ b/homeassistant/components/gpslogger/device_tracker.py
@@ -2,13 +2,11 @@
from typing import override
-from homeassistant.components.device_tracker import TrackerEntity
-from homeassistant.const import (
- ATTR_BATTERY_LEVEL,
- ATTR_GPS_ACCURACY,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
+from homeassistant.components.device_tracker import (
+ TrackerEntity,
+ TrackerEntityStateAttribute,
)
+from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -120,9 +118,11 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity):
return
attr = state.attributes
- self._attr_latitude = attr.get(ATTR_LATITUDE)
- self._attr_longitude = attr.get(ATTR_LONGITUDE)
- self._attr_location_accuracy = attr.get(ATTR_GPS_ACCURACY, 0)
+ self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE)
+ self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE)
+ self._attr_location_accuracy = attr.get(
+ TrackerEntityStateAttribute.GPS_ACCURACY, 0
+ )
self._attr_extra_state_attributes = {
ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE),
ATTR_ACTIVITY: attr.get(ATTR_ACTIVITY),
diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py
index 2e39f2e8226b..773bdd40899e 100644
--- a/homeassistant/components/gree/climate.py
+++ b/homeassistant/components/gree/climate.py
@@ -26,10 +26,6 @@ from homeassistant.components.climate import (
PRESET_ECO,
PRESET_NONE,
PRESET_SLEEP,
- SWING_BOTH,
- SWING_HORIZONTAL,
- SWING_OFF,
- SWING_VERTICAL,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
@@ -57,7 +53,7 @@ HVAC_MODES = {
Mode.Fan: HVACMode.FAN_ONLY,
Mode.Heat: HVACMode.HEAT,
}
-HVAC_MODES_REVERSE = {v: k for k, v in HVAC_MODES.items()}
+HVAC_MODES_INVERSE = {v: k for k, v in HVAC_MODES.items()}
PRESET_MODES = [
PRESET_ECO, # Power saving mode
@@ -75,9 +71,38 @@ FAN_MODES = {
FanSpeed.MediumHigh: FAN_MEDIUM_HIGH,
FanSpeed.High: FAN_HIGH,
}
-FAN_MODES_REVERSE = {v: k for k, v in FAN_MODES.items()}
+FAN_MODES_INVERSE = {v: k for k, v in FAN_MODES.items()}
-SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH]
+VERTICAL_SWING_MODES: dict[str, VerticalSwing] = {
+ "default": VerticalSwing.Default,
+ "full_swing": VerticalSwing.FullSwing,
+ "fixed_upper": VerticalSwing.FixedUpper,
+ "fixed_upper_middle": VerticalSwing.FixedUpperMiddle,
+ "fixed_middle": VerticalSwing.FixedMiddle,
+ "fixed_lower_middle": VerticalSwing.FixedLowerMiddle,
+ "fixed_lower": VerticalSwing.FixedLower,
+ "swing_upper": VerticalSwing.SwingUpper,
+ "swing_upper_middle": VerticalSwing.SwingUpperMiddle,
+ "swing_middle": VerticalSwing.SwingMiddle,
+ "swing_lower_middle": VerticalSwing.SwingLowerMiddle,
+ "swing_lower": VerticalSwing.SwingLower,
+}
+VERTICAL_SWING_MODES_INVERSE: dict[VerticalSwing, str] = {
+ v: k for k, v in VERTICAL_SWING_MODES.items()
+}
+
+HORIZONTAL_SWING_MODES: dict[str, HorizontalSwing] = {
+ "default": HorizontalSwing.Default,
+ "full_swing": HorizontalSwing.FullSwing,
+ "left": HorizontalSwing.Left,
+ "left_center": HorizontalSwing.LeftCenter,
+ "center": HorizontalSwing.Center,
+ "right_center": HorizontalSwing.RightCenter,
+ "right": HorizontalSwing.Right,
+}
+HORIZONTAL_SWING_MODES_INVERSE: dict[HorizontalSwing, str] = {
+ v: k for k, v in HORIZONTAL_SWING_MODES.items()
+}
async def async_setup_entry(
@@ -109,15 +134,18 @@ class GreeClimateEntity(GreeEntity, ClimateEntity):
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.SWING_MODE
+ | ClimateEntityFeature.SWING_HORIZONTAL_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_target_temperature_step = TARGET_TEMPERATURE_STEP
- _attr_hvac_modes = [*HVAC_MODES_REVERSE, HVACMode.OFF]
+ _attr_hvac_modes = [*HVAC_MODES_INVERSE, HVACMode.OFF]
_attr_preset_modes = PRESET_MODES
- _attr_fan_modes = [*FAN_MODES_REVERSE]
- _attr_swing_modes = SWING_MODES
+ _attr_fan_modes = [*FAN_MODES_INVERSE]
+ _attr_swing_modes = [*VERTICAL_SWING_MODES]
+ _attr_swing_horizontal_modes = [*HORIZONTAL_SWING_MODES]
_attr_name = None
+ _attr_translation_key = "climate"
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = TEMP_MIN
_attr_max_temp = TEMP_MAX
@@ -189,7 +217,7 @@ class GreeClimateEntity(GreeEntity, ClimateEntity):
if not self.coordinator.device.power:
self.coordinator.device.power = True
- self.coordinator.device.mode = HVAC_MODES_REVERSE.get(hvac_mode)
+ self.coordinator.device.mode = HVAC_MODES_INVERSE.get(hvac_mode)
await self.coordinator.push_state_update()
self.async_write_ha_state()
@@ -264,47 +292,60 @@ class GreeClimateEntity(GreeEntity, ClimateEntity):
@override
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
- if fan_mode not in FAN_MODES_REVERSE:
+ if fan_mode not in FAN_MODES_INVERSE:
raise ValueError(f"Invalid fan mode: {fan_mode}")
- self.coordinator.device.fan_speed = FAN_MODES_REVERSE.get(fan_mode)
+ self.coordinator.device.fan_speed = FAN_MODES_INVERSE.get(fan_mode)
await self.coordinator.push_state_update()
self.async_write_ha_state()
@property
@override
- def swing_mode(self) -> str:
- """Return the current swing mode for the device."""
- h_swing = self.coordinator.device.horizontal_swing == HorizontalSwing.FullSwing
- v_swing = self.coordinator.device.vertical_swing == VerticalSwing.FullSwing
-
- if h_swing and v_swing:
- return SWING_BOTH
- if h_swing:
- return SWING_HORIZONTAL
- if v_swing:
- return SWING_VERTICAL
- return SWING_OFF
+ def swing_mode(self) -> str | None:
+ """Return the current vertical swing mode for the device."""
+ try:
+ return VERTICAL_SWING_MODES_INVERSE.get(
+ VerticalSwing(self.coordinator.device.vertical_swing)
+ )
+ except ValueError:
+ return None
@override
async def async_set_swing_mode(self, swing_mode: str) -> None:
- """Set new target swing operation."""
- if swing_mode not in SWING_MODES:
- raise ValueError(f"Invalid swing mode: {swing_mode}")
-
+ """Set new target vertical swing operation."""
_LOGGER.debug(
- "Setting swing mode to %s for device %s",
+ "Setting vertical swing mode to %s for device %s",
swing_mode,
self._attr_name,
)
- self.coordinator.device.horizontal_swing = HorizontalSwing.Center
- self.coordinator.device.vertical_swing = VerticalSwing.FixedMiddle
- if swing_mode in (SWING_BOTH, SWING_HORIZONTAL):
- self.coordinator.device.horizontal_swing = HorizontalSwing.FullSwing
- if swing_mode in (SWING_BOTH, SWING_VERTICAL):
- self.coordinator.device.vertical_swing = VerticalSwing.FullSwing
+ self.coordinator.device.vertical_swing = VERTICAL_SWING_MODES[swing_mode]
+ await self.coordinator.push_state_update()
+ self.async_write_ha_state()
+ @property
+ @override
+ def swing_horizontal_mode(self) -> str | None:
+ """Return the current horizontal swing mode for the device."""
+ try:
+ return HORIZONTAL_SWING_MODES_INVERSE.get(
+ HorizontalSwing(self.coordinator.device.horizontal_swing)
+ )
+ except ValueError:
+ return None
+
+ @override
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new target horizontal swing operation."""
+ _LOGGER.debug(
+ "Setting horizontal swing mode to %s for device %s",
+ swing_horizontal_mode,
+ self._attr_name,
+ )
+
+ self.coordinator.device.horizontal_swing = HORIZONTAL_SWING_MODES[
+ swing_horizontal_mode
+ ]
await self.coordinator.push_state_update()
self.async_write_ha_state()
diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json
index 153919fb0dce..cbf1cef49405 100644
--- a/homeassistant/components/gree/strings.json
+++ b/homeassistant/components/gree/strings.json
@@ -11,6 +11,39 @@
}
},
"entity": {
+ "climate": {
+ "climate": {
+ "state_attributes": {
+ "swing_horizontal_mode": {
+ "state": {
+ "center": "Center",
+ "default": "Default",
+ "full_swing": "Full swing",
+ "left": "Left",
+ "left_center": "Left center",
+ "right": "Right",
+ "right_center": "Right center"
+ }
+ },
+ "swing_mode": {
+ "state": {
+ "default": "Default",
+ "fixed_lower": "Fixed lower",
+ "fixed_lower_middle": "Fixed lower middle",
+ "fixed_middle": "Fixed middle",
+ "fixed_upper": "Fixed upper",
+ "fixed_upper_middle": "Fixed upper middle",
+ "full_swing": "Full swing",
+ "swing_lower": "Swing lower",
+ "swing_lower_middle": "Swing lower middle",
+ "swing_middle": "Swing middle",
+ "swing_upper": "Swing upper",
+ "swing_upper_middle": "Swing upper middle"
+ }
+ }
+ }
+ }
+ },
"switch": {
"fresh_air": {
"name": "Fresh air"
diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py
index 2061ce831cf8..2fafaa192806 100644
--- a/homeassistant/components/group/__init__.py
+++ b/homeassistant/components/group/__init__.py
@@ -23,6 +23,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.group import (
expand_entity_ids as _expand_entity_ids,
get_entity_ids as _get_entity_ids,
+ get_group_entities,
)
from homeassistant.helpers.reload import async_reload_integration_platforms
from homeassistant.helpers.typing import ConfigType
@@ -122,14 +123,42 @@ def groups_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]:
Async friendly.
"""
- if DOMAIN not in hass.data:
- return []
+ groups: list[str] = []
- return [
- group.entity_id
- for group in hass.data[DATA_COMPONENT].entities
- if entity_id in group.tracking
- ]
+ if DOMAIN in hass.data:
+ groups.extend(
+ group.entity_id
+ for group in hass.data[DATA_COMPONENT].entities
+ if entity_id in group.tracking
+ )
+
+ groups.extend(
+ group_entity_id
+ for group_entity_id, entity in get_group_entities(hass).items()
+ if entity.group is not None
+ and entity_id in entity.group.member_entity_ids
+ and group_entity_id not in groups
+ )
+
+ # Config entry groups whose platform does not (yet) register in
+ # the group entities registry of the group helper.
+ entity_registry = er.async_get(hass)
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ members = [
+ er.async_resolve_entity_id(entity_registry, member) or member
+ for member in entry.options[CONF_ENTITIES]
+ ]
+ if entity_id not in members:
+ continue
+ groups.extend(
+ registry_entry.entity_id
+ for registry_entry in er.async_entries_for_config_entry(
+ entity_registry, entry.entry_id
+ )
+ if registry_entry.entity_id not in groups
+ )
+
+ return groups
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/group/icons.json b/homeassistant/components/group/icons.json
index e3084bf950d6..f79b1e3b24f7 100644
--- a/homeassistant/components/group/icons.json
+++ b/homeassistant/components/group/icons.json
@@ -1,4 +1,14 @@
{
+ "entity": {
+ "light": {
+ "light": {
+ "default": "mdi:lightbulb-group",
+ "state": {
+ "off": "mdi:lightbulb-group-off"
+ }
+ }
+ }
+ },
"services": {
"reload": {
"service": "mdi:reload"
diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py
index ace3878655a3..68f922272b4c 100644
--- a/homeassistant/components/group/light.py
+++ b/homeassistant/components/group/light.py
@@ -147,7 +147,7 @@ class LightGroup(GroupEntity, LightEntity):
"""Representation of a light group."""
_attr_available = False
- _attr_icon = "mdi:lightbulb-group"
+ _attr_translation_key = "light"
_attr_max_color_temp_kelvin = 6500
_attr_min_color_temp_kelvin = 2000
_attr_should_poll = False
diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py
index 819f5c8edbd0..abf9118c8fa5 100644
--- a/homeassistant/components/growatt_server/__init__.py
+++ b/homeassistant/components/growatt_server/__init__.py
@@ -23,6 +23,7 @@ Error handling pattern for reauth:
→ raise ConfigEntryAuthFailed
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
"""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from collections.abc import Mapping
import datetime
@@ -452,10 +453,7 @@ async def async_setup_entry(
for device_sn in device_domain_ids:
if coordinator := runtime_data.devices.pop(device_sn, None):
await coordinator.async_shutdown()
- device_registry.async_update_device(
- device_entry.id,
- remove_config_entry_id=config_entry.entry_id,
- )
+ device_registry.async_remove_device(device_entry.id)
# Add new devices
new_coordinators: list[GrowattCoordinator] = []
diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json
index 1b062a9a073a..09f3f81b6060 100644
--- a/homeassistant/components/guntamatic/manifest.json
+++ b/homeassistant/components/guntamatic/manifest.json
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
- "requirements": ["guntamatic==1.9.0"]
+ "requirements": ["guntamatic==1.9.2"]
}
diff --git a/homeassistant/components/harbor/__init__.py b/homeassistant/components/harbor/__init__.py
new file mode 100644
index 000000000000..1f10679688d0
--- /dev/null
+++ b/homeassistant/components/harbor/__init__.py
@@ -0,0 +1,43 @@
+"""The Harbor integration."""
+
+from harbor.config import HarborCameraConfig
+
+from homeassistant.const import CONF_IP_ADDRESS
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+
+from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN, PLATFORMS
+from .coordinator import HarborConfigEntry, HarborCoordinator
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool:
+ """Set up Harbor from a config entry."""
+ coordinator = HarborCoordinator(
+ hass,
+ entry,
+ HarborCameraConfig(
+ serial=entry.data[CONF_SERIAL],
+ cert_pem=entry.data[CONF_CERT_PEM],
+ key_pem=entry.data[CONF_KEY_PEM],
+ ip_address=entry.data[CONF_IP_ADDRESS],
+ ),
+ )
+ await coordinator.async_start()
+ try:
+ await coordinator.async_wait_until_ready()
+ except TimeoutError as err:
+ await coordinator.async_shutdown()
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN, translation_key="cannot_connect"
+ ) from err
+ entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool:
+ """Unload a Harbor config entry."""
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ await entry.runtime_data.async_shutdown()
+ return unload_ok
diff --git a/homeassistant/components/harbor/config_flow.py b/homeassistant/components/harbor/config_flow.py
new file mode 100644
index 000000000000..05d222fea54c
--- /dev/null
+++ b/homeassistant/components/harbor/config_flow.py
@@ -0,0 +1,128 @@
+"""Config flow for Harbor."""
+
+from typing import Any, override
+
+from harbor.config import HarborCameraConfig
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_IP_ADDRESS
+from homeassistant.helpers import selector
+
+from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN
+from .coordinator import async_probe_camera
+
+SERIAL_LENGTH = 10
+
+STEP_USER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_SERIAL): selector.TextSelector(selector.TextSelectorConfig()),
+ vol.Required(CONF_CERT_PEM): selector.TextSelector(
+ selector.TextSelectorConfig(multiline=True)
+ ),
+ vol.Required(CONF_KEY_PEM): selector.TextSelector(
+ selector.TextSelectorConfig(multiline=True)
+ ),
+ vol.Required(CONF_IP_ADDRESS): selector.TextSelector(
+ selector.TextSelectorConfig()
+ ),
+ }
+)
+
+
+def _validate_serial(value: str) -> bool:
+ """Validate the Harbor serial number."""
+ return len(value) == SERIAL_LENGTH and value.isdigit()
+
+
+def _validate_cert_pem(value: str) -> bool:
+ """Validate a Harbor client certificate PEM blob."""
+ value = value.strip()
+ return value.startswith("-----BEGIN CERTIFICATE-----") and value.endswith(
+ "-----END CERTIFICATE-----"
+ )
+
+
+def _validate_key_pem(value: str) -> bool:
+ """Validate a Harbor private key PEM blob."""
+ value = value.strip()
+ return value.startswith("-----BEGIN PRIVATE KEY-----") and value.endswith(
+ "-----END PRIVATE KEY-----"
+ )
+
+
+def _validate_credentials(cert_pem: str, key_pem: str) -> dict[str, str]:
+ """Validate cert/key PEM blobs and return any errors."""
+ errors: dict[str, str] = {}
+ if not _validate_cert_pem(cert_pem):
+ errors[CONF_CERT_PEM] = "invalid_cert"
+ if not _validate_key_pem(key_pem):
+ errors[CONF_KEY_PEM] = "invalid_key"
+ return errors
+
+
+class HarborConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Harbor."""
+
+ VERSION = 1
+
+ @override
+ 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_SCHEMA,
+ errors={},
+ )
+
+ normalized = {
+ key: value.strip() if isinstance(value, str) else value
+ for key, value in user_input.items()
+ }
+ errors: dict[str, str] = {}
+ display_name: str | None = None
+
+ serial = normalized[CONF_SERIAL]
+ if not _validate_serial(serial):
+ errors[CONF_SERIAL] = "invalid_serial"
+
+ errors.update(
+ _validate_credentials(normalized[CONF_CERT_PEM], normalized[CONF_KEY_PEM])
+ )
+
+ if not errors:
+ await self.async_set_unique_id(serial)
+ self._abort_if_unique_id_configured()
+
+ config = HarborCameraConfig(
+ serial=serial,
+ cert_pem=normalized[CONF_CERT_PEM],
+ key_pem=normalized[CONF_KEY_PEM],
+ ip_address=normalized[CONF_IP_ADDRESS],
+ )
+ try:
+ display_name = await async_probe_camera(config)
+ except TimeoutError:
+ errors["base"] = "cannot_connect"
+
+ if errors:
+ return self.async_show_form(
+ step_id="user",
+ data_schema=STEP_USER_SCHEMA,
+ errors=errors,
+ )
+
+ entry_data: dict[str, Any] = {
+ CONF_SERIAL: serial,
+ CONF_CERT_PEM: normalized[CONF_CERT_PEM],
+ CONF_KEY_PEM: normalized[CONF_KEY_PEM],
+ CONF_IP_ADDRESS: normalized[CONF_IP_ADDRESS],
+ }
+
+ return self.async_create_entry(
+ title=display_name or f"Camera {serial}",
+ data=entry_data,
+ )
diff --git a/homeassistant/components/harbor/const.py b/homeassistant/components/harbor/const.py
new file mode 100644
index 000000000000..f9b5670e332b
--- /dev/null
+++ b/homeassistant/components/harbor/const.py
@@ -0,0 +1,13 @@
+"""Constants for the Harbor integration."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "harbor"
+MANUFACTURER = "Harbor"
+MODEL = "Harbor Camera"
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+CONF_CERT_PEM = "cert_pem"
+CONF_KEY_PEM = "key_pem"
+CONF_SERIAL = "serial"
diff --git a/homeassistant/components/harbor/coordinator.py b/homeassistant/components/harbor/coordinator.py
new file mode 100644
index 000000000000..55afb751b663
--- /dev/null
+++ b/homeassistant/components/harbor/coordinator.py
@@ -0,0 +1,176 @@
+"""Coordinator for Harbor."""
+
+import asyncio
+import logging
+from typing import Any, override
+from uuid import uuid4
+
+from harbor.config import HarborCameraConfig
+from harbor.devices.camera import HarborCamera
+from harbor.mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient
+from harbor.state import HarborDeviceState
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import instance_id
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN, MANUFACTURER, MODEL
+
+LOGGER = logging.getLogger(__name__)
+
+type HarborConfigEntry = ConfigEntry[HarborCoordinator]
+
+# How long to wait for the first successful MQTT connection and the first
+# device data to arrive before treating the camera as unreachable, both when
+# validating the config flow and during setup.
+CONNECT_TIMEOUT = 30.0
+
+
+async def _discard_message(topic: str, payload: Any) -> None:
+ """Ignore messages received while probing the connection."""
+
+
+async def async_probe_camera(config: HarborCameraConfig) -> str | None:
+ """Connect to a Harbor camera and return its friendly name, if any.
+
+ Raises ``TimeoutError`` when no MQTT session can be established with the
+ camera. Returns the camera's configured display name, or ``None`` when the
+ camera is reachable but has no name (or does not answer the settings
+ request in time).
+ """
+ connected = asyncio.Event()
+
+ async def _on_connection_change(is_connected: bool) -> None:
+ if is_connected:
+ connected.set()
+
+ client = HarborMQTTClient(
+ config=config,
+ # Subscribe to the responses topic so the get-settings reply can be
+ # matched to its pending request; without a subscription the reply
+ # never reaches the client and the request would time out.
+ topics=[f"cameras/{config.serial}/responses/#"],
+ message_handler=_discard_message,
+ client_id=f"{DOMAIN}-{config.serial}-probe-{uuid4().hex[:8]}",
+ on_connection_change=_on_connection_change,
+ connection_grace_period=0,
+ )
+ await client.start()
+ try:
+ async with asyncio.timeout(CONNECT_TIMEOUT):
+ await connected.wait()
+ try:
+ settings = await client.get_settings()
+ except TimeoutError, ConnectionError:
+ return None
+ if settings.settings is None:
+ return None
+ return settings.settings.preference_display_name
+ finally:
+ await client.stop()
+
+
+class HarborCoordinator(DataUpdateCoordinator[HarborDeviceState]):
+ """Own the MQTT transport and state for a single Harbor camera."""
+
+ config_entry: HarborConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: HarborConfigEntry,
+ config: HarborCameraConfig,
+ ) -> None:
+ """Initialize the Harbor coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=entry,
+ name=f"{DOMAIN}_{config.serial}",
+ )
+ self._config = config
+ self.device = HarborCamera(config)
+ self.data = self.device.state
+ self.connected = False
+ self._ssl_context_cache: dict[str, Any] = {}
+ self._mqtt_client: HarborMQTTClient | None = None
+ self._connected_event = asyncio.Event()
+ self._data_event = asyncio.Event()
+ self._unsubscribe_updates = self.device.subscribe_updates(
+ self._handle_device_update
+ )
+
+ async def async_start(self) -> None:
+ """Start the Harbor MQTT client."""
+ hass_instance_id = await instance_id.async_get(self.hass)
+ client_id = (
+ f"{DOMAIN}-{hass_instance_id[:8]}-"
+ f"{self.config_entry.entry_id[:8]}-{self._config.serial}"
+ )
+ self._mqtt_client = HarborMQTTClient(
+ config=self._config,
+ topics=self.device.get_topics(),
+ message_handler=self.device.handle_message,
+ client_id=client_id,
+ ssl_context_cache=self._ssl_context_cache,
+ on_connection_change=self._async_set_connected,
+ # Fetch the full settings snapshot on every (re)connection so the
+ # device name and settings-derived state populate immediately
+ # instead of waiting for the next heartbeat.
+ initial_commands=DEFAULT_INITIAL_COMMANDS,
+ )
+ await self._mqtt_client.start()
+
+ async def async_wait_until_ready(self) -> None:
+ """Wait for the first MQTT connection and the first device data.
+
+ Registering entities only once the camera's first message has
+ arrived means the device registry sees the real name and firmware
+ from the start, instead of a placeholder that would otherwise
+ persist until the next reload.
+
+ Raises ``TimeoutError`` if the camera does not connect and report
+ data in time.
+ """
+ async with asyncio.timeout(CONNECT_TIMEOUT):
+ await self._connected_event.wait()
+ await self._data_event.wait()
+
+ @override
+ async def async_shutdown(self) -> None:
+ """Stop the MQTT client and release device resources."""
+ await super().async_shutdown()
+ if self._mqtt_client is not None:
+ await self._mqtt_client.stop()
+ self._mqtt_client = None
+ self._unsubscribe_updates()
+ self.device.shutdown()
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return device info for the Harbor camera."""
+ state = self.data
+ return DeviceInfo(
+ identifiers={(DOMAIN, state.serial)},
+ manufacturer=MANUFACTURER,
+ model=MODEL,
+ name=state.display_name or f"{MODEL} {state.serial}",
+ serial_number=state.serial,
+ sw_version=state.os_version,
+ )
+
+ def _handle_device_update(self, state: HarborDeviceState) -> None:
+ """Mirror a library device update into Home Assistant."""
+ self._data_event.set()
+ self.async_set_updated_data(state)
+
+ async def _async_set_connected(self, connected: bool) -> None:
+ """Propagate the MQTT connection state to entity availability."""
+ if connected:
+ self._connected_event.set()
+ if self.connected == connected:
+ return
+ self.connected = connected
+ self.async_update_listeners()
diff --git a/homeassistant/components/harbor/entity.py b/homeassistant/components/harbor/entity.py
new file mode 100644
index 000000000000..b04a3b3269bf
--- /dev/null
+++ b/homeassistant/components/harbor/entity.py
@@ -0,0 +1,37 @@
+"""Base entities for Harbor."""
+
+from typing import override
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import HarborCoordinator
+
+
+class HarborEntity(CoordinatorEntity[HarborCoordinator]):
+ """Base Harbor entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: HarborCoordinator,
+ unique_key: str,
+ ) -> None:
+ """Initialize the Harbor entity."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.data.serial}_{unique_key}"
+
+ @override
+ @property
+ def available(self) -> bool:
+ """Return if the entity is currently available."""
+ if not self.coordinator.connected:
+ return False
+ return self.coordinator.data.last_seen is not None
+
+ @override
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return the device info for the backing Harbor device."""
+ return self.coordinator.device_info
diff --git a/homeassistant/components/harbor/icons.json b/homeassistant/components/harbor/icons.json
new file mode 100644
index 000000000000..50c18c3b3a7e
--- /dev/null
+++ b/homeassistant/components/harbor/icons.json
@@ -0,0 +1,15 @@
+{
+ "entity": {
+ "sensor": {
+ "num_viewers": {
+ "default": "mdi:account-eye"
+ },
+ "stream_quality": {
+ "default": "mdi:signal"
+ },
+ "wifi_strength": {
+ "default": "mdi:wifi"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/harbor/manifest.json b/homeassistant/components/harbor/manifest.json
new file mode 100644
index 000000000000..a9f927b12828
--- /dev/null
+++ b/homeassistant/components/harbor/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "harbor",
+ "name": "Harbor Sleep",
+ "codeowners": ["@Lash-L", "@afgarcia86"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/harbor",
+ "integration_type": "device",
+ "iot_class": "local_push",
+ "loggers": ["harbor"],
+ "quality_scale": "bronze",
+ "requirements": ["harbor-python==1.2.1"]
+}
diff --git a/homeassistant/components/harbor/quality_scale.yaml b/homeassistant/components/harbor/quality_scale.yaml
new file mode 100644
index 000000000000..9fb9660e4441
--- /dev/null
+++ b/homeassistant/components/harbor/quality_scale.yaml
@@ -0,0 +1,73 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: This integration does not provide additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: This integration is push-based via MQTT and 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 provide additional actions.
+ docs-conditions:
+ status: exempt
+ comment: This integration does not have any conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: This integration does not have any triggers.
+ entity-event-setup:
+ status: exempt
+ comment: Entities receive updates via the coordinator and do not subscribe to events directly.
+ 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: todo
+ docs-configuration-parameters: todo
+
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: todo
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ 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: todo
+ 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: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/harbor/sensor.py b/homeassistant/components/harbor/sensor.py
new file mode 100644
index 000000000000..ee1d03260895
--- /dev/null
+++ b/homeassistant/components/harbor/sensor.py
@@ -0,0 +1,97 @@
+"""Sensor entities for Harbor."""
+
+from typing import override
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from .coordinator import HarborConfigEntry, HarborCoordinator
+from .entity import HarborEntity
+
+PARALLEL_UPDATES = 0
+
+CAMERA_SENSORS: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key="num_viewers",
+ translation_key="num_viewers",
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="bitrate",
+ translation_key="bitrate",
+ device_class=SensorDeviceClass.DATA_RATE,
+ native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="wifi_strength",
+ translation_key="wifi_strength",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="stream_quality",
+ translation_key="stream_quality",
+ device_class=SensorDeviceClass.ENUM,
+ options=["excellent", "fair", "good", "poor"],
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: HarborConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Harbor sensors from a config entry."""
+ coordinator = entry.runtime_data
+ async_add_entities(
+ HarborSensor(coordinator, description) for description in CAMERA_SENSORS
+ )
+
+
+class HarborSensor(HarborEntity, SensorEntity):
+ """A Harbor sensor entity."""
+
+ def __init__(
+ self,
+ coordinator: HarborCoordinator,
+ description: SensorEntityDescription,
+ ) -> None:
+ """Initialize the Harbor sensor."""
+ self.entity_description = description
+ super().__init__(coordinator, description.key)
+
+ @override
+ @property
+ def native_value(self) -> StateType:
+ """Return the current sensor value."""
+ value = self.coordinator.data.values.get(self.entity_description.key)
+ if (
+ self.entity_description.device_class == SensorDeviceClass.ENUM
+ and value == "unknown"
+ ):
+ # The library falls back to the literal string "unknown" for any
+ # enum value it doesn't recognize; surface that as no value
+ # rather than a bogus member of the options list.
+ return None
+ return value
diff --git a/homeassistant/components/harbor/strings.json b/homeassistant/components/harbor/strings.json
new file mode 100644
index 000000000000..1d4c0bae7c10
--- /dev/null
+++ b/homeassistant/components/harbor/strings.json
@@ -0,0 +1,59 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_cert": "The client certificate must be a valid PEM certificate",
+ "invalid_key": "The private key must be a valid PEM private key",
+ "invalid_serial": "The serial number must be exactly 10 digits"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "cert_pem": "Client certificate",
+ "ip_address": "[%key:common::config_flow::data::ip%]",
+ "key_pem": "Private key",
+ "serial": "Serial number"
+ },
+ "data_description": {
+ "cert_pem": "Paste the client certificate from the Harbor app.",
+ "ip_address": "The local IP address of the Harbor device.",
+ "key_pem": "Paste the private key that matches the client certificate.",
+ "serial": "The 10-digit serial number printed on the Harbor device."
+ },
+ "title": "Set up Harbor"
+ }
+ }
+ },
+ "entity": {
+ "sensor": {
+ "bitrate": {
+ "name": "Bitrate"
+ },
+ "num_viewers": {
+ "name": "Viewers",
+ "unit_of_measurement": "viewers"
+ },
+ "stream_quality": {
+ "name": "Stream quality",
+ "state": {
+ "excellent": "Excellent",
+ "fair": "Fair",
+ "good": "Good",
+ "poor": "Poor"
+ }
+ },
+ "wifi_strength": {
+ "name": "Wi-Fi strength",
+ "unit_of_measurement": "bars"
+ }
+ }
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "Could not connect to the Harbor camera. It may be offline or unreachable."
+ }
+ }
+}
diff --git a/homeassistant/components/harman_luxury/__init__.py b/homeassistant/components/harman_luxury/__init__.py
new file mode 100644
index 000000000000..c8e4c9ae8d47
--- /dev/null
+++ b/homeassistant/components/harman_luxury/__init__.py
@@ -0,0 +1,31 @@
+"""The Harman Luxury Audio integration."""
+
+from aioharmanluxury import HarmanLuxuryClient
+
+from homeassistant.const import CONF_HOST, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .coordinator import HarmanLuxuryConfigEntry, HarmanLuxuryCoordinator
+
+_PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: HarmanLuxuryConfigEntry
+) -> bool:
+ """Set up Harman Luxury from a config entry."""
+ client = HarmanLuxuryClient(entry.data[CONF_HOST], async_get_clientsession(hass))
+ coordinator = HarmanLuxuryCoordinator(hass, entry, client)
+ 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: HarmanLuxuryConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/harman_luxury/config_flow.py b/homeassistant/components/harman_luxury/config_flow.py
new file mode 100644
index 000000000000..9008d164257d
--- /dev/null
+++ b/homeassistant/components/harman_luxury/config_flow.py
@@ -0,0 +1,92 @@
+"""Config flow for the Harman Luxury integration."""
+
+from typing import Any, override
+from urllib.parse import urlparse
+
+from aioharmanluxury import DeviceInfo, HarmanLuxuryClient, HarmanLuxuryError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo
+
+from .const import DOMAIN
+
+STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
+
+
+class HarmanLuxuryConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Harman Luxury."""
+
+ _host: str
+ _name: str
+
+ async def _async_get_info(self, host: str) -> DeviceInfo | None:
+ """Return the device info, or ``None`` if it has no usable identity."""
+ client = HarmanLuxuryClient(host, async_get_clientsession(self.hass))
+ try:
+ info = await client.async_get_info()
+ except HarmanLuxuryError:
+ return None
+ if not info.serial:
+ return None
+ return info
+
+ @override
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initiated by the user."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ info = await self._async_get_info(user_input[CONF_HOST])
+ if info is None:
+ errors["base"] = "cannot_connect"
+ else:
+ await self.async_set_unique_id(info.serial)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=info.name, data=user_input)
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ @override
+ async def async_step_ssdp(
+ self, discovery_info: SsdpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a flow initiated by SSDP discovery."""
+ host = urlparse(discovery_info.ssdp_location or "").hostname
+ serial = discovery_info.upnp.get(ATTR_UPNP_SERIAL)
+ if not host or 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})
+
+ info = await self._async_get_info(host)
+ # The unique ID is the advertised serial; refuse a device whose API
+ # reports a different one, so setup cannot later fail on the mismatch.
+ if info is None or info.serial != serial:
+ return self.async_abort(reason="cannot_connect")
+
+ self._host = host
+ self._name = info.name
+ self.context["title_placeholders"] = {"name": info.name}
+ return await self.async_step_discovery_confirm()
+
+ async def async_step_discovery_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm setup of a discovered device."""
+ if user_input is not None:
+ return self.async_create_entry(
+ title=self._name, data={CONF_HOST: self._host}
+ )
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ description_placeholders={"name": self._name},
+ )
diff --git a/homeassistant/components/harman_luxury/const.py b/homeassistant/components/harman_luxury/const.py
new file mode 100644
index 000000000000..6c8d79b7b6fe
--- /dev/null
+++ b/homeassistant/components/harman_luxury/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Harman Luxury integration."""
+
+DOMAIN = "harman_luxury"
diff --git a/homeassistant/components/harman_luxury/coordinator.py b/homeassistant/components/harman_luxury/coordinator.py
new file mode 100644
index 000000000000..c54d0adeef2c
--- /dev/null
+++ b/homeassistant/components/harman_luxury/coordinator.py
@@ -0,0 +1,75 @@
+"""Data update coordinator for Harman Luxury."""
+
+from datetime import datetime, timedelta
+import logging
+from typing import override
+
+from aioharmanluxury import (
+ DeviceInfo,
+ HarmanLuxuryClient,
+ HarmanLuxuryError,
+ HarmanLuxuryState,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryError
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util import dt as dt_util
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+type HarmanLuxuryConfigEntry = ConfigEntry[HarmanLuxuryCoordinator]
+
+_SCAN_INTERVAL = timedelta(seconds=10)
+
+
+class HarmanLuxuryCoordinator(DataUpdateCoordinator[HarmanLuxuryState]):
+ """Poll a Harman Luxury device for its live player state."""
+
+ config_entry: HarmanLuxuryConfigEntry
+ device_info: DeviceInfo
+ position_updated_at: datetime | None = None
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: HarmanLuxuryConfigEntry,
+ client: HarmanLuxuryClient,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=config_entry.title,
+ update_interval=_SCAN_INTERVAL,
+ )
+ self.client = client
+
+ @override
+ async def _async_setup(self) -> None:
+ """Fetch static device identity once."""
+ try:
+ self.device_info = await self.client.async_get_info()
+ except HarmanLuxuryError as err:
+ raise UpdateFailed(str(err)) from err
+ if self.device_info.serial != self.config_entry.unique_id:
+ raise ConfigEntryError(
+ translation_domain=DOMAIN,
+ translation_key="unexpected_device",
+ )
+
+ @override
+ async def _async_update_data(self) -> HarmanLuxuryState:
+ """Fetch the latest player state."""
+ try:
+ state = await self.client.async_get_state()
+ except HarmanLuxuryError as err:
+ raise UpdateFailed(str(err)) from err
+ self.position_updated_at = (
+ dt_util.utcnow() if state.position is not None else None
+ )
+ return state
diff --git a/homeassistant/components/harman_luxury/manifest.json b/homeassistant/components/harman_luxury/manifest.json
new file mode 100644
index 000000000000..f935743154c9
--- /dev/null
+++ b/homeassistant/components/harman_luxury/manifest.json
@@ -0,0 +1,21 @@
+{
+ "domain": "harman_luxury",
+ "name": "Harman Luxury Audio",
+ "codeowners": ["@sbesh91"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/harman_luxury",
+ "integration_type": "device",
+ "iot_class": "local_polling",
+ "quality_scale": "bronze",
+ "requirements": ["aioharmanluxury==0.2.3"],
+ "ssdp": [
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
+ "manufacturer": "Harman Luxury Audio"
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
+ "manufacturer": "Harman Luxury Audio"
+ }
+ ]
+}
diff --git a/homeassistant/components/harman_luxury/media_player.py b/homeassistant/components/harman_luxury/media_player.py
new file mode 100644
index 000000000000..322a3b4bdddf
--- /dev/null
+++ b/homeassistant/components/harman_luxury/media_player.py
@@ -0,0 +1,209 @@
+"""Media player platform for Harman Luxury."""
+
+from collections.abc import Coroutine
+from datetime import datetime
+from typing import Any, override
+
+from aioharmanluxury import HarmanLuxuryClient, HarmanLuxuryError
+
+from homeassistant.components.media_player import (
+ MediaPlayerDeviceClass,
+ MediaPlayerEntity,
+ MediaPlayerEntityFeature,
+ MediaPlayerState,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import HarmanLuxuryConfigEntry, HarmanLuxuryCoordinator
+
+# The device serializes control on a single session; serialize at HA's layer.
+PARALLEL_UPDATES = 1
+
+# The device exposes volume on a 0..99 scale.
+_VOLUME_MAX = 99
+
+_PLAY_STATE_MAP = {
+ "playing": MediaPlayerState.PLAYING,
+ "paused": MediaPlayerState.PAUSED,
+ "stopped": MediaPlayerState.IDLE,
+ "buffering": MediaPlayerState.BUFFERING,
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: HarmanLuxuryConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the media player from a config entry."""
+ async_add_entities([HarmanLuxuryMediaPlayer(entry.runtime_data)])
+
+
+class HarmanLuxuryMediaPlayer(
+ CoordinatorEntity[HarmanLuxuryCoordinator], MediaPlayerEntity
+):
+ """Representation of a Harman Luxury streamer."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_device_class = MediaPlayerDeviceClass.SPEAKER
+ _attr_volume_step = 1 / _VOLUME_MAX
+
+ _BASE_FEATURES = (
+ MediaPlayerEntityFeature.VOLUME_SET
+ | MediaPlayerEntityFeature.VOLUME_STEP
+ | MediaPlayerEntityFeature.VOLUME_MUTE
+ )
+
+ def __init__(self, coordinator: HarmanLuxuryCoordinator) -> None:
+ """Initialize the media player."""
+ super().__init__(coordinator)
+ info = coordinator.device_info
+ self._attr_unique_id = info.serial
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, info.serial)},
+ connections={(CONNECTION_NETWORK_MAC, info.mac)} if info.mac else set(),
+ manufacturer="Harman Luxury Audio",
+ model=info.model,
+ name=info.name,
+ )
+
+ @property
+ def _client(self) -> HarmanLuxuryClient:
+ """Return the device client."""
+ return self.coordinator.client
+
+ @property
+ @override
+ def state(self) -> MediaPlayerState:
+ """Return the state of the device."""
+ data = self.coordinator.data
+ if not data.online:
+ return MediaPlayerState.OFF
+ return _PLAY_STATE_MAP.get(data.play_state, MediaPlayerState.ON)
+
+ @property
+ @override
+ def supported_features(self) -> MediaPlayerEntityFeature:
+ """Return the supported features."""
+ features = self._BASE_FEATURES
+ data = self.coordinator.data
+ if data.can_play:
+ features |= MediaPlayerEntityFeature.PLAY
+ if data.can_pause:
+ features |= MediaPlayerEntityFeature.PAUSE
+ if data.can_stop:
+ features |= MediaPlayerEntityFeature.STOP
+ if data.can_next:
+ features |= MediaPlayerEntityFeature.NEXT_TRACK
+ if data.can_previous:
+ features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
+ return features
+
+ @property
+ @override
+ def volume_level(self) -> float:
+ """Return the volume level (0..1)."""
+ return self.coordinator.data.volume / _VOLUME_MAX
+
+ @property
+ @override
+ def is_volume_muted(self) -> bool:
+ """Return whether the output is muted."""
+ return self.coordinator.data.muted
+
+ @property
+ @override
+ def media_title(self) -> str | None:
+ """Return the title of the current media."""
+ return self.coordinator.data.title
+
+ @property
+ @override
+ def media_artist(self) -> str | None:
+ """Return the artist of the current media."""
+ return self.coordinator.data.artist
+
+ @property
+ @override
+ def media_album_name(self) -> str | None:
+ """Return the album of the current media."""
+ return self.coordinator.data.album
+
+ @property
+ @override
+ def media_image_url(self) -> str | None:
+ """Return the album art URL."""
+ return self.coordinator.data.art_url
+
+ @property
+ @override
+ def media_duration(self) -> int | None:
+ """Return the duration of the current media, in seconds."""
+ duration = self.coordinator.data.duration
+ return int(duration) if duration is not None else None
+
+ @property
+ @override
+ def media_position(self) -> int | None:
+ """Return the position of the current media, in seconds."""
+ position = self.coordinator.data.position
+ return int(position) if position is not None else None
+
+ @property
+ @override
+ def media_position_updated_at(self) -> datetime | None:
+ """Return when the media position was last retrieved."""
+ return self.coordinator.position_updated_at
+
+ async def _async_send(self, coro: Coroutine[Any, Any, None]) -> None:
+ """Run a client command, translating failures and refreshing state."""
+ try:
+ await coro
+ except HarmanLuxuryError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="command_failed"
+ ) from err
+ await self.coordinator.async_request_refresh()
+
+ @override
+ async def async_set_volume_level(self, volume: float) -> None:
+ """Set the volume level."""
+ await self._async_send(
+ self._client.async_set_volume(round(volume * _VOLUME_MAX))
+ )
+
+ @override
+ async def async_mute_volume(self, mute: bool) -> None:
+ """Mute or unmute the output."""
+ await self._async_send(self._client.async_set_mute(mute))
+
+ @override
+ async def async_media_play(self) -> None:
+ """Resume playback."""
+ await self._async_send(self._client.async_control("play"))
+
+ @override
+ async def async_media_pause(self) -> None:
+ """Pause playback."""
+ await self._async_send(self._client.async_control("pause"))
+
+ @override
+ async def async_media_stop(self) -> None:
+ """Stop playback."""
+ await self._async_send(self._client.async_control("stop"))
+
+ @override
+ async def async_media_next_track(self) -> None:
+ """Skip to the next track."""
+ await self._async_send(self._client.async_control("next"))
+
+ @override
+ async def async_media_previous_track(self) -> None:
+ """Skip to the previous track."""
+ await self._async_send(self._client.async_control("previous"))
diff --git a/homeassistant/components/harman_luxury/quality_scale.yaml b/homeassistant/components/harman_luxury/quality_scale.yaml
new file mode 100644
index 000000000000..e0c52e495ea4
--- /dev/null
+++ b/homeassistant/components/harman_luxury/quality_scale.yaml
@@ -0,0 +1,82 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: This integration does not register any custom service 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 register any custom service actions.
+ docs-conditions:
+ status: exempt
+ comment: This integration does not register any conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: This integration does not register any triggers.
+ 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: done
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: The device API is unauthenticated; there are no credentials to refresh.
+ 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: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: A config entry maps to a single device; there are no dynamic sub-devices.
+ entity-category: todo
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: The single media player entity is the primary entity and stays enabled.
+ entity-translations:
+ status: exempt
+ comment: The media player uses the device name via has-entity-name.
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: There are no repairable conditions surfaced by the device.
+ stale-devices:
+ status: exempt
+ comment: A config entry maps to a single device; removal is via entry deletion.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/harman_luxury/strings.json b/homeassistant/components/harman_luxury/strings.json
new file mode 100644
index 000000000000..1cf7c1f6a672
--- /dev/null
+++ b/homeassistant/components/harman_luxury/strings.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "flow_title": "{name}",
+ "step": {
+ "discovery_confirm": {
+ "description": "Do you want to set up {name}?"
+ },
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of your Harman Luxury device."
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "command_failed": {
+ "message": "Failed to send the command to the device."
+ },
+ "unexpected_device": {
+ "message": "The device at this address reports a different serial number than the configured device."
+ }
+ }
+}
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index b38caf53c336..231e7be94867 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -60,6 +60,8 @@ from .const import (
DATA_HASSIO_SUPERVISOR_USER,
DATA_KEY_SUPERVISOR_ISSUES,
DOMAIN,
+ ISSUE_MOUNT_MOUNT_FAILED,
+ JOBS_COORDINATOR,
MAIN_COORDINATOR,
STATS_COORDINATOR,
)
@@ -67,6 +69,10 @@ from .coordinator import (
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsDataUpdateCoordinator,
+ IssueSubscription,
+ IssueSubscriptionEvent,
+ SupervisorIssuesCoordinator,
+ SupervisorJobsCoordinator,
get_addons_info,
get_addons_list,
get_addons_stats,
@@ -85,7 +91,6 @@ from .exceptions import HassioNotReadyError
from .handler import HassIO, async_update_diagnostics, get_supervisor_client
from .http import HassIOView
from .ingress import async_setup_ingress_view
-from .issues import SupervisorIssues
from .services import async_setup_services
from .websocket_api import async_load_websocket_api
@@ -326,9 +331,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data[MAIN_COORDINATOR] = coordinator
- addon_coordinator = HassioAddOnDataUpdateCoordinator(
- hass, entry, dev_reg, coordinator.jobs
- )
+ jobs_coordinator = SupervisorJobsCoordinator(hass, entry)
+ await jobs_coordinator.async_config_entry_first_refresh()
+ hass.data[JOBS_COORDINATOR] = jobs_coordinator
+
+ addon_coordinator = HassioAddOnDataUpdateCoordinator(hass, entry, dev_reg)
await addon_coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = addon_coordinator
@@ -336,18 +343,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await stats_coordinator.async_config_entry_first_refresh()
hass.data[STATS_COORDINATOR] = stats_coordinator
- # All coordinators refreshed successfully. Start the issues listener and
- # install the stop handler now so they are never left in a partial state
- # if a coordinator refresh raises ConfigEntryNotReady.
- hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
+ issues_coordinator = SupervisorIssuesCoordinator(hass, entry)
+ hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues_coordinator
- def _unload_supervisor_issues() -> None:
- if (
- supervisor_issues := hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None)
- ) is not None:
- supervisor_issues.unload()
+ @callback
+ def _refresh_main_coordinator_on_mount_issue(_: IssueSubscriptionEvent) -> None:
+ coordinator.config_entry.async_create_task(hass, coordinator.async_refresh())
- entry.async_on_unload(_unload_supervisor_issues)
+ entry.async_on_unload(
+ issues_coordinator.subscribe(
+ IssueSubscription(
+ event_callback=_refresh_main_coordinator_on_mount_issue,
+ key=ISSUE_MOUNT_MOUNT_FAILED,
+ )
+ )
+ )
async def _async_stop(hass: HomeAssistant, restart: bool) -> None:
"""Stop or restart home assistant."""
@@ -403,9 +413,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"Failed to update Home Assistant options in Supervisor: %s", err
)
- # Push initial config to Supervisor and start issues listener
+ # Push initial config to Supervisor and refresh issues state
await asyncio.gather(
- update_hass_api(refresh_token), push_config(None), issues.setup()
+ update_hass_api(refresh_token),
+ push_config(None),
+ issues_coordinator.async_refresh(),
)
# Setup hardware integration for the detected board type
@@ -437,5 +449,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.pop(MAIN_COORDINATOR, None)
hass.data.pop(ADDONS_COORDINATOR, None)
hass.data.pop(STATS_COORDINATOR, None)
+ hass.data.pop(JOBS_COORDINATOR, None)
+ hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None)
return unload_ok
diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py
index c800e03b0a8c..6b8dcba7cf99 100644
--- a/homeassistant/components/hassio/backup.py
+++ b/homeassistant/components/hassio/backup.py
@@ -48,14 +48,13 @@ from homeassistant.components.backup import (
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.dispatcher import async_dispatcher_connect
-from homeassistant.util import dt as dt_util
+from homeassistant.util import dt as dt_util, slugify
from homeassistant.util.enum import try_parse_enum
from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT
@@ -68,6 +67,16 @@ TAG_ADDON_UPDATE = "supervisor.addon_update"
_LOGGER = logging.getLogger(__name__)
+def _suggested_backup_filename(name: str, date: str) -> str:
+ """Suggest a filename for a Supervisor backup.
+
+ Slugify the name so a display name with path separators (e.g. an add-on
+ named "Nabu Casa / Webhook Proxy") can't produce a filename Supervisor
+ rejects. The unsanitized name is still stored as the backup's display name.
+ """
+ return suggested_filename_from_name_date(slugify(name), date)
+
+
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
@@ -202,7 +211,7 @@ class SupervisorBackupAgent(BackupAgent):
stream = await open_stream()
upload_options = supervisor_backups.UploadBackupOptions(
location={self.location},
- filename=PurePath(suggested_backup_filename(backup)),
+ filename=PurePath(_suggested_backup_filename(backup.name, backup.date)),
)
async def stream_with_progress() -> AsyncIterator[bytes]:
@@ -361,7 +370,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
date = dt_util.now().isoformat()
extra_metadata = extra_metadata | {"supervisor.backup_request_date": date}
- filename = suggested_filename_from_name_date(backup_name, date)
+ filename = _suggested_backup_filename(backup_name, date)
try:
backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py
index 418f956c46c2..a33ab7fb1115 100644
--- a/homeassistant/components/hassio/const.py
+++ b/homeassistant/components/hassio/const.py
@@ -27,9 +27,10 @@ if TYPE_CHECKING:
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsDataUpdateCoordinator,
+ SupervisorIssuesCoordinator,
+ SupervisorJobsCoordinator,
)
from .handler import HassIO
- from .issues import SupervisorIssues
DOMAIN = "hassio"
@@ -103,6 +104,9 @@ ADDONS_COORDINATOR: HassKey[HassioAddOnDataUpdateCoordinator] = HassKey(
STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey(
"hassio_stats_coordinator"
)
+JOBS_COORDINATOR: HassKey[SupervisorJobsCoordinator] = HassKey(
+ "hassio_jobs_coordinator"
+)
DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
@@ -126,6 +130,8 @@ DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list")
HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5)
HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15)
HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60)
+HASSIO_ISSUES_UPDATE_INTERVAL = timedelta(minutes=30)
+SUPERVISOR_JOBS_UPDATE_INTERVAL = timedelta(minutes=15)
ATTR_AUTO_UPDATE = "auto_update"
ATTR_VERSION = "version"
@@ -143,7 +149,9 @@ DATA_KEY_OS = "os"
DATA_KEY_SUPERVISOR = "supervisor"
DATA_KEY_CORE = "core"
DATA_KEY_HOST = "host"
-DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssues] = HassKey("supervisor_issues")
+DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssuesCoordinator] = HassKey(
+ "supervisor_issues"
+)
DATA_KEY_MOUNTS = "mounts"
DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host")
DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user")
@@ -155,6 +163,7 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
PLACEHOLDER_KEY_FREE_SPACE = "free_space"
+PLACEHOLDER_KEY_REASON = "reason"
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py
index b08cd67939eb..0dd9e2da0187 100644
--- a/homeassistant/components/hassio/coordinator.py
+++ b/homeassistant/components/hassio/coordinator.py
@@ -2,10 +2,11 @@
import asyncio
from collections import defaultdict
-from collections.abc import Awaitable
-from dataclasses import dataclass
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, replace
import logging
-from typing import TYPE_CHECKING, Any, cast, override
+from typing import Any, Literal, cast, override
+from uuid import UUID
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import (
@@ -17,6 +18,8 @@ from aiohasupervisor.models import (
HostInfo,
InstalledAddon,
InstalledAddonComplete,
+ Issue as SupervisorIssue,
+ Job,
NetworkInfo,
NFSMountResponse,
OSInfo,
@@ -25,22 +28,39 @@ from aiohasupervisor.models import (
StoreInfo,
SupervisorInfo,
SupervisorStats,
+ UnhealthyReason,
+ UnsupportedReason,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_MANUFACTURER
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ HomeAssistant,
+ callback,
+ is_callback_check_partial,
+)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.issue_registry import (
+ IssueSeverity,
+ async_create_issue,
+ async_delete_issue,
+)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_ADDONS,
ATTR_DATA,
+ ATTR_HEALTHY,
ATTR_REPOSITORIES,
+ ATTR_SLUG,
ATTR_STARTUP,
+ ATTR_SUPPORTED,
+ ATTR_UNHEALTHY_REASONS,
+ ATTR_UNSUPPORTED_REASONS,
ATTR_UPDATE_KEY,
ATTR_WS_EVENT,
CONTAINER_STATS,
@@ -59,26 +79,667 @@ from .const import (
DATA_SUPERVISOR_INFO,
DATA_SUPERVISOR_STATS,
DOMAIN,
+ EVENT_HEALTH_CHANGED,
+ EVENT_ISSUE_CHANGED,
+ EVENT_ISSUE_REMOVED,
+ EVENT_JOB,
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
+ EVENT_SUPPORTED_CHANGED,
+ EXTRA_PLACEHOLDERS,
HASSIO_ADDON_UPDATE_INTERVAL,
+ HASSIO_ISSUES_UPDATE_INTERVAL,
HASSIO_MAIN_UPDATE_INTERVAL,
HASSIO_STATS_UPDATE_INTERVAL,
+ ISSUE_KEY_ADDON_BOOT_FAIL,
+ ISSUE_KEY_ADDON_DEPRECATED_ARCH,
+ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
+ ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
+ ISSUE_KEY_ADDON_PWNED,
+ ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
+ ISSUE_KEY_SYSTEM_FREE_SPACE,
+ ISSUE_MOUNT_MOUNT_FAILED,
+ PLACEHOLDER_KEY_ADDON,
+ PLACEHOLDER_KEY_ADDON_URL,
+ PLACEHOLDER_KEY_FREE_SPACE,
+ PLACEHOLDER_KEY_REASON,
+ PLACEHOLDER_KEY_REFERENCE,
REQUEST_REFRESH_DELAY,
STARTUP_COMPLETE,
SUPERVISOR_CONTAINER,
+ SUPERVISOR_JOBS_UPDATE_INTERVAL,
UPDATE_KEY_SUPERVISOR,
SupervisorEntityModel,
)
from .exceptions import HassioNotReadyError
from .handler import get_supervisor_client
-from .jobs import SupervisorJobs
-
-if TYPE_CHECKING:
- from .issues import SupervisorIssues
+from .issues import Issue, IssueDataType, Suggestion
_LOGGER = logging.getLogger(__name__)
+ISSUE_KEY_UNHEALTHY = "unhealthy"
+ISSUE_KEY_UNSUPPORTED = "unsupported"
+ISSUE_ID_UNHEALTHY = "unhealthy_system"
+ISSUE_ID_UNSUPPORTED = "unsupported_system"
+
+INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy"
+INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"
+
+# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason
+# provides no additional information beyond the unhealthy one then skip that repair.
+UNSUPPORTED_SKIP_REPAIR = {"privileged"}
+
+# Keys (type + context) of issues that when found should be made into a repair.
+ISSUE_KEYS_FOR_REPAIRS = {
+ ISSUE_KEY_ADDON_BOOT_FAIL,
+ ISSUE_MOUNT_MOUNT_FAILED,
+ "issue_system_multiple_data_disks",
+ "issue_system_reboot_required",
+ ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
+ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
+ ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
+ "issue_system_disk_lifetime",
+ ISSUE_KEY_SYSTEM_FREE_SPACE,
+ ISSUE_KEY_ADDON_PWNED,
+ ISSUE_KEY_ADDON_DEPRECATED_ARCH,
+ "issue_system_ntp_sync_failed",
+}
+
+
+@dataclass(slots=True, frozen=True)
+class IssueSubscription:
+ """Subscribe for updates on supervisor issues matching a key."""
+
+ event_callback: Callable[[IssueSubscriptionEvent], None]
+ key: str
+
+ def __post_init__(self) -> None:
+ """Validate inputs."""
+ if not self.key:
+ raise ValueError("A key must be provided!")
+ if not is_callback_check_partial(self.event_callback):
+ raise ValueError("event_callback must be a homeassistant.core.callback!")
+
+ def matches(self, issue: Issue) -> bool:
+ """Return true if issue matches this subscription."""
+ return issue.key == self.key
+
+
+@dataclass(slots=True, frozen=True)
+class IssueSubscriptionEvent:
+ """Issue subscription event."""
+
+ event: Literal["changed", "removed"]
+ issue: Issue
+
+
+@dataclass(slots=True, frozen=True)
+class SupervisorIssuesData:
+ """Data class for supervisor issues."""
+
+ unhealthy_reasons: set[str]
+ unsupported_reasons: set[str]
+ issues: dict[UUID, Issue]
+
+
+class SupervisorIssuesCoordinator(DataUpdateCoordinator[SupervisorIssuesData]):
+ """Manage supervisor issues state and repair synchronization."""
+
+ config_entry: ConfigEntry
+
+ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ """Initialize supervisor issues coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name="SupervisorIssuesCoordinator",
+ update_interval=HASSIO_ISSUES_UPDATE_INTERVAL,
+ request_refresh_debouncer=Debouncer(
+ hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
+ ),
+ )
+ self._supervisor_client = get_supervisor_client(hass)
+ self._subscriptions: set[IssueSubscription] = set()
+ self._dispatcher_disconnect: Callable[[], None] | None = (
+ async_dispatcher_connect(
+ self.hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues
+ )
+ )
+ # Keep polling active even if initial refresh fails so coordinator can recover.
+ self.async_add_listener(lambda: None)
+
+ @property
+ def unhealthy_reasons(self) -> set[str]:
+ """Get unhealthy reasons. Returns empty set if system is healthy."""
+ return self.data.unhealthy_reasons if self.data is not None else set()
+
+ @property
+ def unsupported_reasons(self) -> set[str]:
+ """Get unsupported reasons. Returns empty set if system is supported."""
+ return self.data.unsupported_reasons if self.data is not None else set()
+
+ @property
+ def issues(self) -> set[Issue]:
+ """Get issues."""
+ if self.data is None:
+ return set()
+ return set(self.data.issues.values())
+
+ def get_issue(self, issue_id: str) -> Issue | None:
+ """Get issue from key."""
+ if self.data is None:
+ return None
+ return self.data.issues.get(UUID(issue_id))
+
+ def subscribe(self, subscription: IssueSubscription) -> CALLBACK_TYPE:
+ """Subscribe to updates for issue key. Callback is used to unsubscribe."""
+ self._subscriptions.add(subscription)
+
+ for match in [issue for issue in self.issues if subscription.matches(issue)]:
+ self._notify_issue_subscription_event(
+ subscription, IssueSubscriptionEvent(event="changed", issue=match)
+ )
+
+ def _unsubscribe() -> None:
+ self._subscriptions.discard(subscription)
+
+ return _unsubscribe
+
+ def _process_issue_change(self, event: IssueSubscriptionEvent) -> None:
+ """Process an issue change by triggering callbacks on subscribers."""
+ for sub in self._subscriptions:
+ if sub.matches(event.issue):
+ self._notify_issue_subscription_event(sub, event)
+
+ def _notify_issue_subscription_event(
+ self, subscription: IssueSubscription, event: IssueSubscriptionEvent
+ ) -> None:
+ """Run a subscription callback and log callback failures."""
+ try:
+ subscription.event_callback(event)
+ except Exception as err: # noqa: BLE001
+ _LOGGER.error(
+ "Error encountered processing Supervisor issue (%s %s %s) - %s",
+ event.issue.key,
+ event.issue.reference,
+ event.issue.uuid,
+ err,
+ )
+
+ @staticmethod
+ def _issue_equal(previous_issue: Issue, issue: Issue) -> bool:
+ """Return true if issues are equal including suggestions."""
+ return (
+ previous_issue == issue and previous_issue.suggestions == issue.suggestions
+ )
+
+ def _process_reason_deltas(
+ self,
+ previous_data: SupervisorIssuesData,
+ current_data: SupervisorIssuesData,
+ ) -> None:
+ """Create/delete unhealthy and unsupported repairs based on reason deltas."""
+ for unhealthy in (
+ current_data.unhealthy_reasons - previous_data.unhealthy_reasons
+ ):
+ if unhealthy in UnhealthyReason:
+ translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}"
+ translation_placeholders = None
+ else:
+ translation_key = ISSUE_KEY_UNHEALTHY
+ translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy}
+
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"{ISSUE_ID_UNHEALTHY}_{unhealthy}",
+ is_fixable=False,
+ learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}",
+ severity=IssueSeverity.CRITICAL,
+ translation_key=translation_key,
+ translation_placeholders=translation_placeholders,
+ )
+
+ for fixed in previous_data.unhealthy_reasons - current_data.unhealthy_reasons:
+ async_delete_issue(self.hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}")
+
+ for unsupported in (
+ current_data.unsupported_reasons
+ - UNSUPPORTED_SKIP_REPAIR
+ - previous_data.unsupported_reasons
+ ):
+ if unsupported in UnsupportedReason:
+ translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}"
+ translation_placeholders = None
+ else:
+ translation_key = ISSUE_KEY_UNSUPPORTED
+ translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported}
+
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"{ISSUE_ID_UNSUPPORTED}_{unsupported}",
+ is_fixable=False,
+ learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}",
+ severity=IssueSeverity.WARNING,
+ translation_key=translation_key,
+ translation_placeholders=translation_placeholders,
+ )
+
+ for fixed in previous_data.unsupported_reasons - (
+ current_data.unsupported_reasons - UNSUPPORTED_SKIP_REPAIR
+ ):
+ async_delete_issue(self.hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}")
+
+ def _create_or_update_issue_repair(self, issue: Issue) -> None:
+ """Create/update a repair for an issue if needed."""
+ if issue.key not in ISSUE_KEYS_FOR_REPAIRS:
+ return
+
+ if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
+ placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy()
+ else:
+ placeholders = {}
+
+ if issue.reference:
+ placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference
+
+ if issue.key in {
+ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
+ ISSUE_KEY_ADDON_PWNED,
+ }:
+ placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
+ f"/hassio/addon/{issue.reference}"
+ )
+ addons_list = get_addons_list(self.hass) or []
+ placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
+ for addon in addons_list:
+ if addon[ATTR_SLUG] == issue.reference:
+ placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME]
+ break
+
+ elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
+ host_info = get_host_info(self.hass)
+ if host_info and "disk_free" in host_info:
+ placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(host_info["disk_free"])
+ else:
+ placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
+
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ issue.uuid.hex,
+ is_fixable=bool(issue.suggestions),
+ severity=IssueSeverity.WARNING,
+ translation_key=issue.key,
+ translation_placeholders=placeholders or None,
+ )
+
+ def _delete_issue_repair(self, issue: Issue) -> None:
+ """Delete repair for issue if it maps to a repair."""
+ if issue.key in ISSUE_KEYS_FOR_REPAIRS:
+ async_delete_issue(self.hass, DOMAIN, issue.uuid.hex)
+
+ def _process_issue_deltas(
+ self,
+ previous_data: SupervisorIssuesData,
+ current_data: SupervisorIssuesData,
+ ) -> None:
+ """Create/delete issue repairs and notify subscribers based on issue deltas."""
+ for issue in current_data.issues.values():
+ previous_issue = previous_data.issues.get(issue.uuid)
+ if previous_issue is not None and self._issue_equal(previous_issue, issue):
+ continue
+
+ self._create_or_update_issue_repair(issue)
+ self._process_issue_change(
+ IssueSubscriptionEvent(event="changed", issue=issue)
+ )
+
+ for issue_uuid, issue in previous_data.issues.items():
+ if issue_uuid not in current_data.issues:
+ self._delete_issue_repair(issue)
+ self._process_issue_change(
+ IssueSubscriptionEvent(event="removed", issue=issue)
+ )
+
+ @override
+ async def _async_update_data(self) -> SupervisorIssuesData:
+ """Update issues data from Supervisor resolution center."""
+ try:
+ data = await self._supervisor_client.resolution.info()
+ except SupervisorError as err:
+ raise UpdateFailed(f"Error on Supervisor API: {err}") from err
+
+ issue_from_data_results = await asyncio.gather(
+ *(self._issue_from_data(issue) for issue in data.issues)
+ )
+ issues = {
+ issue_from_data.uuid: issue_from_data
+ for issue_from_data in issue_from_data_results
+ if issue_from_data is not None
+ }
+
+ return SupervisorIssuesData(
+ unhealthy_reasons={str(reason) for reason in data.unhealthy},
+ unsupported_reasons={str(reason) for reason in data.unsupported},
+ issues=issues,
+ )
+
+ async def _issue_from_data(self, data: SupervisorIssue) -> Issue | None:
+ """Build an Issue model from Supervisor issue data and fetched suggestions."""
+ try:
+ suggestions = (
+ await self._supervisor_client.resolution.suggestions_for_issue(
+ data.uuid
+ )
+ )
+ except SupervisorError:
+ _LOGGER.error(
+ "Could not get suggestions for supervisor issue %s, skipping it",
+ data.uuid.hex,
+ )
+ return None
+
+ return Issue(
+ uuid=data.uuid,
+ type=str(data.type),
+ context=data.context,
+ reference=data.reference,
+ suggestions=[
+ Suggestion(
+ uuid=suggestion.uuid,
+ type=str(suggestion.type),
+ context=suggestion.context,
+ reference=suggestion.reference,
+ )
+ for suggestion in suggestions
+ ],
+ )
+
+ @override
+ async def _async_refresh(
+ self,
+ log_failures: bool = True,
+ raise_on_auth_failed: bool = False,
+ scheduled: bool = False,
+ raise_on_entry_error: bool = False,
+ ) -> None:
+ """Refresh issue data and apply repair/subscription deltas."""
+ previous_data = self.data or SupervisorIssuesData(set(), set(), {})
+ await super()._async_refresh(
+ log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
+ )
+ if self.last_update_success and self.data is not None:
+ self._process_reason_deltas(previous_data, self.data)
+ self._process_issue_deltas(previous_data, self.data)
+
+ @override
+ async def async_shutdown(self) -> None:
+ """Shut down the coordinator."""
+ await super().async_shutdown()
+ if self._dispatcher_disconnect:
+ self._dispatcher_disconnect()
+ self._dispatcher_disconnect = None
+
+ @callback
+ def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None:
+ """Update issues data from supervisor events."""
+ if ATTR_WS_EVENT not in event:
+ return
+
+ if (
+ event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
+ and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
+ and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE
+ ):
+ self.config_entry.async_create_task(self.hass, self.async_refresh())
+ return
+
+ previous_data = self.data or SupervisorIssuesData(set(), set(), {})
+
+ if event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED:
+ unhealthy_reasons = (
+ set()
+ if event[ATTR_DATA][ATTR_HEALTHY]
+ else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS])
+ )
+ updated_data = SupervisorIssuesData(
+ unhealthy_reasons=unhealthy_reasons,
+ unsupported_reasons=set(previous_data.unsupported_reasons),
+ issues=dict(previous_data.issues),
+ )
+ elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED:
+ unsupported_reasons = (
+ set()
+ if event[ATTR_DATA][ATTR_SUPPORTED]
+ else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS])
+ )
+ updated_data = SupervisorIssuesData(
+ unhealthy_reasons=set(previous_data.unhealthy_reasons),
+ unsupported_reasons=unsupported_reasons,
+ issues=dict(previous_data.issues),
+ )
+ elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED:
+ issue = Issue.from_dict(cast(IssueDataType, event[ATTR_DATA]))
+ updated_issues = dict(previous_data.issues)
+ updated_issues[issue.uuid] = issue
+ updated_data = SupervisorIssuesData(
+ unhealthy_reasons=set(previous_data.unhealthy_reasons),
+ unsupported_reasons=set(previous_data.unsupported_reasons),
+ issues=updated_issues,
+ )
+ elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED:
+ issue = Issue.from_dict(cast(IssueDataType, event[ATTR_DATA]))
+ updated_issues = dict(previous_data.issues)
+ updated_issues.pop(issue.uuid, None)
+ updated_data = SupervisorIssuesData(
+ unhealthy_reasons=set(previous_data.unhealthy_reasons),
+ unsupported_reasons=set(previous_data.unsupported_reasons),
+ issues=updated_issues,
+ )
+ else:
+ return
+
+ self.async_set_updated_data(updated_data)
+ self._process_reason_deltas(previous_data, updated_data)
+ self._process_issue_deltas(previous_data, updated_data)
+
+
+@dataclass(slots=True, frozen=True)
+class JobSubscription:
+ """Subscribe for updates on jobs which match filters.
+
+ UUID is preferred match but only available in cases of a background API that
+ returns the UUID before taking the action. Others are used to match jobs only
+ if UUID is omitted. Either name or UUID is required to be able to match.
+
+ event_callback must be safe annotated as a homeassistant.core.callback
+ and safe to call in the event loop.
+ """
+
+ event_callback: Callable[[Job], None]
+ uuid: str | None = None
+ name: str | None = None
+ reference: str | None = None
+
+ def __post_init__(self) -> None:
+ """Validate at least one filter option is present."""
+ if not self.name and not self.uuid:
+ raise ValueError("Either name or uuid must be provided!")
+ if not is_callback_check_partial(self.event_callback):
+ raise ValueError("event_callback must be a homeassistant.core.callback!")
+
+ def matches(self, job: Job) -> bool:
+ """Return true if job matches subscription filters."""
+ if self.uuid:
+ return job.uuid == self.uuid
+ return job.name == self.name and self.reference in (None, job.reference)
+
+
+class SupervisorJobsCoordinator(DataUpdateCoordinator[dict[UUID, Job]]):
+ """Manage access to Supervisor jobs."""
+
+ config_entry: ConfigEntry
+
+ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ """Initialize object."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name="SupervisorJobsCoordinator",
+ update_interval=SUPERVISOR_JOBS_UPDATE_INTERVAL,
+ # We don't want an immediate refresh since we want to avoid
+ # hammering the Supervisor API on startup
+ request_refresh_debouncer=Debouncer(
+ hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
+ ),
+ )
+ self._supervisor_client = get_supervisor_client(hass)
+ self._subscriptions: set[JobSubscription] = set()
+ self._dispatcher_disconnect: Callable[[], None] | None = None
+ self._noop_listener_disconnect: Callable[[], None] | None = None
+
+ @property
+ def current_jobs(self) -> list[Job]:
+ """Return current jobs."""
+ return list(self.data.values()) if self.data is not None else []
+
+ @staticmethod
+ def _build_jobs(jobs: list[Job]) -> dict[UUID, Job]:
+ """Flatten jobs and child jobs into a UUID keyed cache."""
+ job_queue: list[Job] = jobs.copy()
+ cached_jobs: dict[UUID, Job] = {}
+
+ while job_queue:
+ job = job_queue.pop(0)
+ job_queue.extend(job.child_jobs)
+ cached_jobs[job.uuid] = replace(job, child_jobs=[])
+
+ return cached_jobs
+
+ @override
+ async def _async_update_data(self) -> dict[UUID, Job]:
+ """Fetch data from Supervisor."""
+ job_data = await self._supervisor_client.jobs.info()
+ return self._build_jobs(job_data.jobs)
+
+ def _process_job_change(self, job: Job) -> None:
+ """Process a job change by triggering callbacks on subscribers."""
+ for sub in self._subscriptions:
+ if sub.matches(job):
+ sub.event_callback(job)
+
+ def _process_job_deltas(
+ self,
+ previous_jobs: dict[UUID, Job],
+ current_jobs: dict[UUID, Job],
+ ) -> None:
+ """Notify subscribers about changes between two job caches."""
+ for job in current_jobs.values():
+ if (previous_job := previous_jobs.get(job.uuid)) is not None and (
+ previous_job == job
+ ):
+ continue
+ self._process_job_change(job)
+
+ for uuid, job in previous_jobs.items():
+ if uuid not in current_jobs and job.done is False:
+ self._process_job_change(replace(job, done=True))
+
+ def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
+ """Subscribe to updates for job. Return callback is used to unsubscribe.
+
+ If any jobs match the subscription at the time this is called, runs the
+ callback on them.
+ """
+ self._subscriptions.add(subscription)
+
+ # Connect a stub listener to start the update interval polling on first subscriber
+ if self._noop_listener_disconnect is None:
+ self._noop_listener_disconnect = self.async_add_listener(lambda: None)
+
+ # Run the callback on each existing match
+ # We catch all errors to prevent an error in one from stopping the others
+ for match in [job for job in self.current_jobs if subscription.matches(job)]:
+ try:
+ subscription.event_callback(match)
+ except Exception as err: # noqa: BLE001
+ _LOGGER.error(
+ "Error encountered processing Supervisor Job (%s %s %s) - %s",
+ match.name,
+ match.reference,
+ match.uuid,
+ err,
+ )
+
+ def _unsubscribe() -> None:
+ self._subscriptions.discard(subscription)
+
+ # Stop polling if there are no more subscribers
+ if not self._subscriptions and self._noop_listener_disconnect is not None:
+ self._noop_listener_disconnect()
+ self._noop_listener_disconnect = None
+
+ return _unsubscribe
+
+ @callback
+ @override
+ def _async_refresh_finished(self) -> None:
+ """Register to receive Supervisor events after the first successful refresh."""
+ if self.last_update_success and self._dispatcher_disconnect is None:
+ self._dispatcher_disconnect = async_dispatcher_connect(
+ self.hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
+ )
+
+ @override
+ async def _async_refresh(
+ self,
+ log_failures: bool = True,
+ raise_on_auth_failed: bool = False,
+ scheduled: bool = False,
+ raise_on_entry_error: bool = False,
+ ) -> None:
+ """Refresh data and notify subscribers about cache changes."""
+ previous_jobs = self.data or {}
+ await super()._async_refresh(
+ log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
+ )
+ if self.last_update_success and self.data is not None:
+ self._process_job_deltas(previous_jobs, self.data)
+
+ @override
+ async def async_shutdown(self) -> None:
+ """Shut down the coordinator."""
+ await super().async_shutdown()
+ if self._dispatcher_disconnect:
+ self._dispatcher_disconnect()
+ self._dispatcher_disconnect = None
+
+ @callback
+ def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None:
+ """Update job data cache from supervisor events."""
+ if ATTR_WS_EVENT not in event:
+ return
+
+ if (
+ event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
+ and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
+ and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE
+ ):
+ self.config_entry.async_create_task(self.hass, self.async_request_refresh())
+
+ elif event[ATTR_WS_EVENT] == EVENT_JOB:
+ job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []})
+ previous_jobs = self.data or {}
+ updated_jobs = {**previous_jobs, job.uuid: job}
+ if job.done:
+ updated_jobs.pop(job.uuid, None)
+ self.async_set_updated_data(updated_jobs)
+ self._process_job_change(job)
+
@dataclass
class HassioMainData:
@@ -344,7 +1005,7 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any]:
@callback
-def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
+def get_issues_info(hass: HomeAssistant) -> SupervisorIssuesCoordinator | None:
"""Return Supervisor issues info.
Async friendly.
@@ -591,7 +1252,6 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
hass: HomeAssistant,
config_entry: ConfigEntry,
dev_reg: dr.DeviceRegistry,
- jobs: SupervisorJobs,
) -> None:
"""Initialize coordinator."""
super().__init__(
@@ -610,7 +1270,6 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
self.dev_reg = dev_reg
self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set)
self.supervisor_client = get_supervisor_client(hass)
- self.jobs = jobs
@override
async def _async_update_data(self) -> HassioAddonData:
@@ -752,6 +1411,11 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
)
+ async def async_refresh_after_store_reload(self) -> None:
+ """Refresh addon data when the store was already reloaded externally."""
+ async with self._debounced_refresh.async_lock():
+ await super()._async_refresh(log_failures=True)
+
async def force_addon_info_data_refresh(self, addon_slug: str) -> None:
"""Force refresh of addon info data for a specific addon."""
try:
@@ -800,7 +1464,6 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
self.dev_reg = dev_reg
self.is_hass_os = False
self.supervisor_client = get_supervisor_client(hass)
- self.jobs = SupervisorJobs(hass)
self._dispatcher_disconnect = async_dispatcher_connect(
hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event
)
@@ -854,7 +1517,6 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
),
)
mounts_info = await client.mounts.info()
- await self.jobs.refresh_data(is_first_update)
except SupervisorError as err:
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
@@ -951,4 +1613,3 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
"""Shut down and clean up when config entry unloaded."""
await super().async_shutdown()
self._dispatcher_disconnect()
- self.jobs.unload()
diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py
index a3166d15888d..dc45e57ea2fb 100644
--- a/homeassistant/components/hassio/diagnostics.py
+++ b/homeassistant/components/hassio/diagnostics.py
@@ -2,9 +2,10 @@
from typing import Any
-from attr import asdict
-
-from homeassistant.components.diagnostics import entity_entry_as_dict
+from homeassistant.components.diagnostics import (
+ device_entry_as_dict,
+ entity_entry_as_dict,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -53,7 +54,7 @@ async def async_get_config_entry_diagnostics(
{"entry": entity_entry_as_dict(entity_entry), "state": state_dict}
)
- devices.append({"device": asdict(device), "entities": entities})
+ devices.append({"device": device_entry_as_dict(device), "entities": entities})
return {
"coordinator_data": coordinator.data.to_dict(),
diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py
index b8d14947c0ca..ffdbe4befb29 100644
--- a/homeassistant/components/hassio/issues.py
+++ b/homeassistant/components/hassio/issues.py
@@ -1,100 +1,10 @@
-"""Supervisor events monitor."""
+"""Supervisor issue models."""
-import asyncio
-from collections.abc import Callable
from dataclasses import dataclass, field
-from datetime import datetime
-import logging
-from typing import Any, NotRequired, TypedDict
+from typing import NotRequired, TypedDict
from uuid import UUID
-from aiohasupervisor import SupervisorError
-from aiohasupervisor.models import (
- ContextType,
- Issue as SupervisorIssue,
- UnhealthyReason,
- UnsupportedReason,
-)
-
-from homeassistant.const import ATTR_NAME
-from homeassistant.core import HassJob, HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.event import async_call_later
-from homeassistant.helpers.issue_registry import (
- IssueSeverity,
- async_create_issue,
- async_delete_issue,
-)
-
-from .const import (
- ATTR_DATA,
- ATTR_HEALTHY,
- ATTR_SLUG,
- ATTR_STARTUP,
- ATTR_SUPPORTED,
- ATTR_UNHEALTHY_REASONS,
- ATTR_UNSUPPORTED_REASONS,
- ATTR_UPDATE_KEY,
- ATTR_WS_EVENT,
- DOMAIN,
- EVENT_HEALTH_CHANGED,
- EVENT_ISSUE_CHANGED,
- EVENT_ISSUE_REMOVED,
- EVENT_SUPERVISOR_EVENT,
- EVENT_SUPERVISOR_UPDATE,
- EVENT_SUPPORTED_CHANGED,
- EXTRA_PLACEHOLDERS,
- ISSUE_KEY_ADDON_BOOT_FAIL,
- ISSUE_KEY_ADDON_DEPRECATED_ARCH,
- ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
- ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
- ISSUE_KEY_ADDON_PWNED,
- ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
- ISSUE_KEY_SYSTEM_FREE_SPACE,
- ISSUE_MOUNT_MOUNT_FAILED,
- MAIN_COORDINATOR,
- PLACEHOLDER_KEY_ADDON,
- PLACEHOLDER_KEY_ADDON_URL,
- PLACEHOLDER_KEY_FREE_SPACE,
- PLACEHOLDER_KEY_REFERENCE,
- REQUEST_REFRESH_DELAY,
- STARTUP_COMPLETE,
- UPDATE_KEY_SUPERVISOR,
-)
-from .coordinator import HassioMainDataUpdateCoordinator, get_addons_list, get_host_info
-from .handler import get_supervisor_client
-
-ISSUE_KEY_UNHEALTHY = "unhealthy"
-ISSUE_KEY_UNSUPPORTED = "unsupported"
-ISSUE_ID_UNHEALTHY = "unhealthy_system"
-ISSUE_ID_UNSUPPORTED = "unsupported_system"
-
-INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy"
-INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"
-
-PLACEHOLDER_KEY_REASON = "reason"
-
-# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason
-# provides no additional information beyond the unhealthy one then skip that repair.
-UNSUPPORTED_SKIP_REPAIR = {"privileged"}
-
-# Keys (type + context) of issues that when found should be made into a repair
-ISSUE_KEYS_FOR_REPAIRS = {
- ISSUE_KEY_ADDON_BOOT_FAIL,
- ISSUE_MOUNT_MOUNT_FAILED,
- "issue_system_multiple_data_disks",
- "issue_system_reboot_required",
- ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
- ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
- ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
- "issue_system_disk_lifetime",
- ISSUE_KEY_SYSTEM_FREE_SPACE,
- ISSUE_KEY_ADDON_PWNED,
- ISSUE_KEY_ADDON_DEPRECATED_ARCH,
- "issue_system_ntp_sync_failed",
-}
-
-_LOGGER = logging.getLogger(__name__)
+from aiohasupervisor.models import ContextType
class SuggestionDataType(TypedDict):
@@ -169,277 +79,3 @@ class Issue:
Suggestion.from_dict(suggestion) for suggestion in suggestions
],
)
-
-
-class SupervisorIssues:
- """Create issues from supervisor events."""
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize supervisor issues."""
- self._hass = hass
- self._unsupported_reasons: set[str] = set()
- self._unhealthy_reasons: set[str] = set()
- self._issues: dict[UUID, Issue] = {}
- self._supervisor_client = get_supervisor_client(hass)
- self._disconnect: Callable[[], None] | None = None
- self._cancel_update_retry: Callable[[], None] | None = None
-
- @property
- def unhealthy_reasons(self) -> set[str]:
- """Get unhealthy reasons. Returns empty set if system is healthy."""
- return self._unhealthy_reasons
-
- @unhealthy_reasons.setter
- def unhealthy_reasons(self, reasons: set[str]) -> None:
- """Set unhealthy reasons. Create or delete repairs as necessary."""
- for unhealthy in reasons - self.unhealthy_reasons:
- if unhealthy in UnhealthyReason:
- translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}"
- translation_placeholders = None
- else:
- translation_key = ISSUE_KEY_UNHEALTHY
- translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy}
-
- async_create_issue(
- self._hass,
- DOMAIN,
- f"{ISSUE_ID_UNHEALTHY}_{unhealthy}",
- is_fixable=False,
- learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}",
- severity=IssueSeverity.CRITICAL,
- translation_key=translation_key,
- translation_placeholders=translation_placeholders,
- )
-
- for fixed in self.unhealthy_reasons - reasons:
- async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}")
-
- self._unhealthy_reasons = reasons
-
- @property
- def unsupported_reasons(self) -> set[str]:
- """Get unsupported reasons. Returns empty set if system is supported."""
- return self._unsupported_reasons
-
- @unsupported_reasons.setter
- def unsupported_reasons(self, reasons: set[str]) -> None:
- """Set unsupported reasons. Create or delete repairs as necessary."""
- for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons:
- if unsupported in UnsupportedReason:
- translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}"
- translation_placeholders = None
- else:
- translation_key = ISSUE_KEY_UNSUPPORTED
- translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported}
-
- async_create_issue(
- self._hass,
- DOMAIN,
- f"{ISSUE_ID_UNSUPPORTED}_{unsupported}",
- is_fixable=False,
- learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}",
- severity=IssueSeverity.WARNING,
- translation_key=translation_key,
- translation_placeholders=translation_placeholders,
- )
-
- for fixed in self.unsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR):
- async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}")
-
- self._unsupported_reasons = reasons
-
- @property
- def issues(self) -> set[Issue]:
- """Get issues."""
- return set(self._issues.values())
-
- def add_issue(self, issue: Issue) -> None:
- """Add or update an issue in the list.
-
- Create or update a repair if necessary.
- """
- if issue.key in ISSUE_KEYS_FOR_REPAIRS:
- if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
- placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy()
- else:
- placeholders = {}
-
- if issue.reference:
- placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference
-
- if issue.key in {
- ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
- ISSUE_KEY_ADDON_PWNED,
- }:
- placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
- f"/hassio/addon/{issue.reference}"
- )
- addons_list = get_addons_list(self._hass) or []
- placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
- for addon in addons_list:
- if addon[ATTR_SLUG] == issue.reference:
- placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME]
- break
-
- elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
- host_info = get_host_info(self._hass)
- if host_info and "disk_free" in host_info:
- placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(
- host_info["disk_free"]
- )
- else:
- placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
-
- if issue.key == ISSUE_MOUNT_MOUNT_FAILED:
- self._async_coordinator_refresh()
-
- async_create_issue(
- self._hass,
- DOMAIN,
- issue.uuid.hex,
- is_fixable=bool(issue.suggestions),
- severity=IssueSeverity.WARNING,
- translation_key=issue.key,
- translation_placeholders=placeholders or None,
- )
-
- self._issues[issue.uuid] = issue
-
- async def add_issue_from_data(self, data: SupervisorIssue) -> None:
- """Add issue from data to list after getting latest suggestions."""
- try:
- suggestions = (
- await self._supervisor_client.resolution.suggestions_for_issue(
- data.uuid
- )
- )
- except SupervisorError:
- _LOGGER.error(
- "Could not get suggestions for supervisor issue %s, skipping it",
- data.uuid.hex,
- )
- return
- self.add_issue(
- Issue(
- uuid=data.uuid,
- type=str(data.type),
- context=data.context,
- reference=data.reference,
- suggestions=[
- Suggestion(
- uuid=suggestion.uuid,
- type=str(suggestion.type),
- context=suggestion.context,
- reference=suggestion.reference,
- )
- for suggestion in suggestions
- ],
- )
- )
-
- def remove_issue(self, issue: Issue) -> None:
- """Remove an issue from the list. Delete a repair if necessary."""
- if issue.uuid not in self._issues:
- return
-
- if issue.key in ISSUE_KEYS_FOR_REPAIRS:
- async_delete_issue(self._hass, DOMAIN, issue.uuid.hex)
-
- if issue.key == ISSUE_MOUNT_MOUNT_FAILED:
- self._async_coordinator_refresh()
-
- del self._issues[issue.uuid]
-
- def get_issue(self, issue_id: str) -> Issue | None:
- """Get issue from key."""
- return self._issues.get(UUID(issue_id))
-
- async def setup(self) -> None:
- """Create supervisor events listener."""
- await self.async_update()
-
- self._disconnect = async_dispatcher_connect(
- self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues
- )
-
- def unload(self) -> None:
- """Remove supervisor events listener."""
- if self._disconnect is not None:
- self._disconnect()
- self._disconnect = None
- if self._cancel_update_retry is not None:
- self._cancel_update_retry()
- self._cancel_update_retry = None
-
- async def async_update(self) -> None:
- """Update issues from Supervisor resolution center."""
- if self._cancel_update_retry:
- self._cancel_update_retry()
- self._cancel_update_retry = None
- await self._update()
-
- async def _update(self, _: datetime | None = None) -> None:
- """Update issues from Supervisor resolution center with retry on failure."""
- try:
- data = await self._supervisor_client.resolution.info()
- except SupervisorError as err:
- _LOGGER.error("Failed to update supervisor issues: %r", err)
- self._cancel_update_retry = async_call_later(
- self._hass,
- REQUEST_REFRESH_DELAY,
- HassJob(self._update, cancel_on_shutdown=True),
- )
- return
- self._cancel_update_retry = None
- self.unhealthy_reasons = set(data.unhealthy)
- self.unsupported_reasons = set(data.unsupported)
-
- # Remove any cached issues that weren't returned
- for issue_id in set(self._issues) - {issue.uuid for issue in data.issues}:
- self.remove_issue(self._issues[issue_id])
-
- # Add/update any issues that came back
- await asyncio.gather(
- *[self.add_issue_from_data(issue) for issue in data.issues]
- )
-
- @callback
- def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None:
- """Create issues from supervisor events."""
- if ATTR_WS_EVENT not in event:
- return
-
- if (
- event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
- and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
- and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE
- ):
- self._hass.async_create_task(self.async_update())
-
- elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED:
- self.unhealthy_reasons = (
- set()
- if event[ATTR_DATA][ATTR_HEALTHY]
- else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS])
- )
-
- elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED:
- self.unsupported_reasons = (
- set()
- if event[ATTR_DATA][ATTR_SUPPORTED]
- else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS])
- )
-
- elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED:
- self.add_issue(Issue.from_dict(event[ATTR_DATA]))
-
- elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED:
- self.remove_issue(Issue.from_dict(event[ATTR_DATA]))
-
- def _async_coordinator_refresh(self) -> None:
- """Refresh coordinator to update latest data in entities."""
- coordinator: HassioMainDataUpdateCoordinator | None
- if coordinator := self._hass.data.get(MAIN_COORDINATOR):
- coordinator.config_entry.async_create_task(
- self._hass, coordinator.async_refresh()
- )
diff --git a/homeassistant/components/hassio/jobs.py b/homeassistant/components/hassio/jobs.py
deleted file mode 100644
index a7445b33b5e9..000000000000
--- a/homeassistant/components/hassio/jobs.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""Track Supervisor job data and allow subscription to updates."""
-
-from collections.abc import Callable
-from dataclasses import dataclass, replace
-from functools import partial
-import logging
-from typing import Any
-from uuid import UUID
-
-from aiohasupervisor.models import Job
-
-from homeassistant.core import (
- CALLBACK_TYPE,
- HomeAssistant,
- callback,
- is_callback_check_partial,
-)
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-
-from .const import (
- ATTR_DATA,
- ATTR_STARTUP,
- ATTR_UPDATE_KEY,
- ATTR_WS_EVENT,
- EVENT_JOB,
- EVENT_SUPERVISOR_EVENT,
- EVENT_SUPERVISOR_UPDATE,
- STARTUP_COMPLETE,
- UPDATE_KEY_SUPERVISOR,
-)
-from .handler import get_supervisor_client
-
-_LOGGER = logging.getLogger(__name__)
-
-
-@dataclass(slots=True, frozen=True)
-class JobSubscription:
- """Subscribe for updates on jobs which match filters.
-
- UUID is preferred match but only available in cases of a background API that
- returns the UUID before taking the action. Others are used to match jobs only
- if UUID is omitted. Either name or UUID is required to be able to match.
-
- event_callback must be safe annotated as a homeassistant.core.callback
- and safe to call in the event loop.
- """
-
- event_callback: Callable[[Job], Any]
- uuid: str | None = None
- name: str | None = None
- reference: str | None = None
-
- def __post_init__(self) -> None:
- """Validate at least one filter option is present."""
- if not self.name and not self.uuid:
- raise ValueError("Either name or uuid must be provided!")
- if not is_callback_check_partial(self.event_callback):
- raise ValueError("event_callback must be a homeassistant.core.callback!")
-
- def matches(self, job: Job) -> bool:
- """Return true if job matches subscription filters."""
- if self.uuid:
- return job.uuid == self.uuid
- return job.name == self.name and self.reference in (None, job.reference)
-
-
-class SupervisorJobs:
- """Manage access to Supervisor jobs."""
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize object."""
- self._hass = hass
- self._supervisor_client = get_supervisor_client(hass)
- self._jobs: dict[UUID, Job] = {}
- self._subscriptions: set[JobSubscription] = set()
- self._dispatcher_disconnect: Callable[[], None] | None = None
-
- @property
- def current_jobs(self) -> list[Job]:
- """Return current jobs."""
- return list(self._jobs.values())
-
- def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
- """Subscribe to updates for job. Return callback is used to unsubscribe.
-
- If any jobs match the subscription at the time this is called, runs the
- callback on them.
- """
- self._subscriptions.add(subscription)
-
- # Run the callback on each existing match
- # We catch all errors to prevent an error in one from stopping the others
- for match in [job for job in self._jobs.values() if subscription.matches(job)]:
- try:
- subscription.event_callback(match)
- except Exception as err: # noqa: BLE001
- _LOGGER.error(
- "Error encountered processing Supervisor Job (%s %s %s) - %s",
- match.name,
- match.reference,
- match.uuid,
- err,
- )
-
- return partial(self._subscriptions.discard, subscription)
-
- async def refresh_data(self, first_update: bool = False) -> None:
- """Refresh job data."""
- job_data = await self._supervisor_client.jobs.info()
- job_queue: list[Job] = job_data.jobs.copy()
- new_jobs: dict[UUID, Job] = {}
- changed_jobs: list[Job] = []
-
- # Rebuild our job cache from new info and compare to find changes
- while job_queue:
- job = job_queue.pop(0)
- job_queue.extend(job.child_jobs)
- job = replace(job, child_jobs=[])
-
- if job.uuid not in self._jobs or job != self._jobs[job.uuid]:
- changed_jobs.append(job)
- new_jobs[job.uuid] = replace(job, child_jobs=[])
-
- # For any jobs that disappeared which weren't done, tell subscribers they
- # changed to done. We don't know what else happened to them so leave the
- # rest of their state as is rather then guessing
- changed_jobs.extend(
- [
- replace(job, done=True)
- for uuid, job in self._jobs.items()
- if uuid not in new_jobs and job.done is False
- ]
- )
-
- # Replace our cache and inform subscribers of all changes
- self._jobs = new_jobs
- for job in changed_jobs:
- self._process_job_change(job)
-
- # If this is the first update register to receive Supervisor events
- if first_update:
- self._dispatcher_disconnect = async_dispatcher_connect(
- self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
- )
-
- @callback
- def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None:
- """Update job data cache from supervisor events."""
- if ATTR_WS_EVENT not in event:
- return
-
- if (
- event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
- and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
- and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE
- ):
- self._hass.async_create_task(self.refresh_data())
-
- elif event[ATTR_WS_EVENT] == EVENT_JOB:
- job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []})
- self._jobs[job.uuid] = job
- self._process_job_change(job)
-
- def _process_job_change(self, job: Job) -> None:
- """Process a job change by triggering callbacks on subscribers."""
- for sub in self._subscriptions:
- if sub.matches(job):
- sub.event_callback(job)
-
- # If the job is done, pop it from our cache if present after processing is done
- if job.done and job.uuid in self._jobs:
- del self._jobs[job.uuid]
-
- @callback
- def unload(self) -> None:
- """Unregister with dispatcher on config entry unload."""
- if self._dispatcher_disconnect:
- self._dispatcher_disconnect()
- self._dispatcher_disconnect = None
diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py
index 10b0c10362fc..afe62408ef55 100644
--- a/homeassistant/components/hassio/update.py
+++ b/homeassistant/components/hassio/update.py
@@ -17,15 +17,19 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR
-from .coordinator import AddonData
+from .const import (
+ ADDONS_COORDINATOR,
+ ATTR_VERSION_LATEST,
+ JOBS_COORDINATOR,
+ MAIN_COORDINATOR,
+)
+from .coordinator import AddonData, JobSubscription
from .entity import (
HassioAddonEntity,
HassioCoreEntity,
HassioOSEntity,
HassioSupervisorEntity,
)
-from .jobs import JobSubscription
from .update_helper import update_addon, update_core, update_os
ENTITY_DESCRIPTION = UpdateEntityDescription(
@@ -220,7 +224,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
"""Subscribe to progress updates."""
await super().async_added_to_hass()
self.async_on_remove(
- self.coordinator.jobs.subscribe(
+ self.hass.data[JOBS_COORDINATOR].subscribe(
JobSubscription(
self._update_job_changed,
name="addon_manager_update",
@@ -278,6 +282,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
) -> None:
"""Install an update."""
await update_os(self.hass, version, backup)
+ await self.coordinator.async_refresh()
@override
async def async_release_notes(self) -> str | None:
@@ -398,7 +403,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
"""Subscribe to progress updates."""
await super().async_added_to_hass()
self.async_on_remove(
- self.coordinator.jobs.subscribe(
+ self.hass.data[JOBS_COORDINATOR].subscribe(
JobSubscription(self._update_job_changed, name="supervisor_update")
)
)
@@ -468,7 +473,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
"""Subscribe to progress updates."""
await super().async_added_to_hass()
self.async_on_remove(
- self.coordinator.jobs.subscribe(
+ self.hass.data[JOBS_COORDINATOR].subscribe(
JobSubscription(
self._update_job_changed, name="home_assistant_core_update"
)
diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py
index ed3034437e1f..dea7dbfbd45a 100644
--- a/homeassistant/components/hassio/websocket_api.py
+++ b/homeassistant/components/hassio/websocket_api.py
@@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import (
from .config import HassioUpdateParametersDict
from .const import (
+ ADDONS_COORDINATOR,
ATTR_DATA,
ATTR_ENDPOINT,
ATTR_METHOD,
@@ -59,6 +60,10 @@ WS_NO_ADMIN_ENDPOINTS = re.compile(
r")$"
)
+# Endpoint that reloads the add-on store. Afterwards the add-on update
+# entities must be refreshed so they don't report stale update information.
+STORE_RELOAD_ENDPOINT = "/store/reload"
+
_LOGGER: logging.Logger = logging.getLogger(__package__)
@@ -159,6 +164,15 @@ async def websocket_supervisor_api(
# sensitive information and the frontend does not require it for ingress.
if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command):
data.pop("options", None)
+ # Await so the frontend only sees the reload finish once the add-on
+ # update entities reflect the reloaded store.
+ if (
+ command == STORE_RELOAD_ENDPOINT
+ and msg[ATTR_METHOD] == "post"
+ and (coordinator := hass.data.get(ADDONS_COORDINATOR))
+ ):
+ await coordinator.async_refresh_after_store_reload()
+
connection.send_result(msg[WS_ID], data)
diff --git a/homeassistant/components/haveibeenpwned/__init__.py b/homeassistant/components/haveibeenpwned/__init__.py
index adead4ec46e0..69b93fc33ebf 100644
--- a/homeassistant/components/haveibeenpwned/__init__.py
+++ b/homeassistant/components/haveibeenpwned/__init__.py
@@ -1 +1 @@
-"""The haveibeenpwned component."""
+"""The HaveIBeenPwned integration."""
diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py
index 1992a0de97f4..19e8dfe88a19 100644
--- a/homeassistant/components/here_travel_time/sensor.py
+++ b/homeassistant/components/here_travel_time/sensor.py
@@ -11,10 +11,9 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_MODE,
CONF_NAME,
+ EntityStateAttribute,
UnitOfLength,
UnitOfTime,
)
@@ -184,9 +183,10 @@ class OriginSensor(HERETravelTimeSensor):
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""GPS coordinates."""
if self.coordinator.data is not None:
+ latitude, longitude = self.coordinator.data[ATTR_ORIGIN].split(",")
return {
- ATTR_LATITUDE: self.coordinator.data[ATTR_ORIGIN].split(",")[0],
- ATTR_LONGITUDE: self.coordinator.data[ATTR_ORIGIN].split(",")[1],
+ EntityStateAttribute.LATITUDE: latitude,
+ EntityStateAttribute.LONGITUDE: longitude,
}
return None
@@ -214,8 +214,9 @@ class DestinationSensor(HERETravelTimeSensor):
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""GPS coordinates."""
if self.coordinator.data is not None:
+ latitude, longitude = self.coordinator.data[ATTR_DESTINATION].split(",")
return {
- ATTR_LATITUDE: self.coordinator.data[ATTR_DESTINATION].split(",")[0],
- ATTR_LONGITUDE: self.coordinator.data[ATTR_DESTINATION].split(",")[1],
+ EntityStateAttribute.LATITUDE: latitude,
+ EntityStateAttribute.LONGITUDE: longitude,
}
return None
diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py
index eceaf0ebdbc0..2d0404f0def3 100644
--- a/homeassistant/components/hikvision/binary_sensor.py
+++ b/homeassistant/components/hikvision/binary_sensor.py
@@ -252,17 +252,29 @@ async def async_setup_entry(
sensor_type,
)
- async_add_entities(
- HikvisionBinarySensor(
- entry=entry,
- description=BINARY_SENSOR_DESCRIPTIONS[sensor_type],
- sensor_type=sensor_type,
- channel=channel_info[1],
- )
- for sensor_type, channel_list in sensors.items()
- if sensor_type in BINARY_SENSOR_DESCRIPTIONS
- for channel_info in channel_list
- )
+ entities: list[HikvisionBinarySensor] = []
+ for sensor_type, channel_list in sensors.items():
+ if sensor_type not in BINARY_SENSOR_DESCRIPTIONS:
+ continue
+ # pyhik can report the same channel more than once for a sensor type
+ # (e.g. when a channel has several notification methods enabled), so
+ # deduplicate on the channel to avoid colliding unique IDs.
+ seen_channels: set[int] = set()
+ for channel_info in channel_list:
+ channel = channel_info[1]
+ if channel in seen_channels:
+ continue
+ seen_channels.add(channel)
+ entities.append(
+ HikvisionBinarySensor(
+ entry=entry,
+ description=BINARY_SENSOR_DESCRIPTIONS[sensor_type],
+ sensor_type=sensor_type,
+ channel=channel,
+ )
+ )
+
+ async_add_entities(entities)
class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity):
diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py
index ebfb13653254..35745d6ebfbb 100644
--- a/homeassistant/components/history_stats/__init__.py
+++ b/homeassistant/components/history_stats/__init__.py
@@ -78,7 +78,6 @@ async def async_setup_entry(
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py
index 1f132870af14..76ed224b5f78 100644
--- a/homeassistant/components/history_stats/data.py
+++ b/homeassistant/components/history_stats/data.py
@@ -197,8 +197,7 @@ class HistoryStats:
finally:
self._query_count -= 1
self._history_current_period = [
- HistoryState(state.state, state.last_changed.timestamp())
- for state in states
+ HistoryState(state.state, state.last_changed_timestamp) for state in states
]
def _state_changes_during_period(
diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json
index 0edf787d5d05..43038c1a8c41 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.99", "babel==2.18.0"]
+ "requirements": ["holidays==0.100", "babel==2.18.0"]
}
diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json
index b44b25fa03ba..7bb66015e5f0 100644
--- a/homeassistant/components/holiday/strings.json
+++ b/homeassistant/components/holiday/strings.json
@@ -51,20 +51,33 @@
"selector": {
"device_class": {
"options": {
+ "albanian": "Albanian",
"armed_forces": "Armed forces",
+ "armenian": "Armenian",
"bank": "Bank",
+ "bosnian": "Bosnian",
"catholic": "Catholic",
"chinese": "Chinese",
"christian": "Christian",
+ "de_facto": "De facto",
"government": "Government",
"half_day": "Half day",
"hebrew": "Hebrew",
"hindu": "Hindu",
"islamic": "Islamic",
"optional": "Optional",
+ "orthodox": "Orthodox",
+ "protestant": "Protestant",
+ "public": "Public",
+ "roma": "Roma",
+ "sabian": "Sabian",
"school": "School",
+ "serbian": "Serbian",
+ "turkish": "Turkish",
"unofficial": "Unofficial",
- "workday": "Workday"
+ "vlach": "Vlach",
+ "workday": "Workday",
+ "yazidi": "Yazidi"
}
}
},
diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py
index 44e475995ffb..c65e96298e01 100644
--- a/homeassistant/components/home_connect/__init__.py
+++ b/homeassistant/components/home_connect/__init__.py
@@ -93,9 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
for device in device_entries:
if not device.identifiers.intersection(appliances_identifiers):
- device_registry.async_update_device(
- device.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(device.id)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py
index 1141b3a29737..94c8c7216af6 100644
--- a/homeassistant/components/home_connect/const.py
+++ b/homeassistant/components/home_connect/const.py
@@ -104,7 +104,7 @@ TRANSLATION_KEYS_PROGRAMS_MAP = {
if program not in (ProgramKey.UNKNOWN, *FAVORITE_PROGRAMS)
}
-PROGRAMS_TRANSLATION_KEYS_MAP = {
+PROGRAMS_TRANSLATION_KEYS_MAP: dict[ProgramKey, str] = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
}
diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py
index 54c6454167b0..5f5a9d52aa0c 100644
--- a/homeassistant/components/homeassistant/__init__.py
+++ b/homeassistant/components/homeassistant/__init__.py
@@ -324,7 +324,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
{
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
- vol.Optional(ATTR_ELEVATION): int,
+ vol.Optional(ATTR_ELEVATION): vol.Coerce(int),
}
),
)
diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py
new file mode 100644
index 000000000000..11c3c9e30caa
--- /dev/null
+++ b/homeassistant/components/homeassistant/llm.py
@@ -0,0 +1,341 @@
+"""LLM tools for the homeassistant integration."""
+
+from decimal import Decimal
+from enum import Enum
+from operator import attrgetter
+from typing import Any, override
+
+import voluptuous as vol
+
+from homeassistant.components.llm import LLMTools
+from homeassistant.components.sensor import SensorDeviceClass, async_rounded_state
+from homeassistant.const import EntityStateAttribute
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import (
+ area_registry as ar,
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+ intent,
+)
+from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput
+from homeassistant.util import dt as dt_util, yaml as yaml_util
+from homeassistant.util.json import JsonObjectType
+
+from .exposed_entities import async_should_expose
+
+# Domains bucketed out of the exposed-entity overview.
+CALENDAR_DOMAIN = "calendar"
+SCRIPT_DOMAIN = "script"
+
+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\n"
+ "the home using the `GetLiveContext` tool."
+ " This is a primary function."
+ " Do not state you lack the\n"
+ "functionality if the question requires live data.\n"
+ "If the user asks about device existence/type"
+ ' (e.g., "Do I have lights in the bedroom?"):'
+ " Answer\n"
+ "from the static context below.\n"
+ "If the user asks about the CURRENT state, value,"
+ ' or mode (e.g., "Is the lock locked?",\n'
+ '"Is the fan on?",'
+ ' "What mode is the thermostat in?",'
+ ' "What is the temperature outside?"):\n'
+ " 1. Recognize this requires live data.\n"
+ " 2. You MUST call `GetLiveContext`."
+ " This tool will provide the needed real-time"
+ " information (like temperature from the local"
+ " weather, lock status, etc.).\n"
+ " 3. Use the tool's response** to answer the"
+ " user accurately"
+ ' (e.g., "The temperature outside is'
+ ' [value from tool].").\n'
+ "For general knowledge questions not about the"
+ " home: Answer truthfully from internal"
+ " knowledge.\n"
+)
+
+
+@callback
+def async_get_exposed_entities(
+ hass: HomeAssistant,
+ assistant: str,
+ include_state: bool = True,
+) -> dict[str, dict[str, Any]]:
+ """Get exposed entities, ignoring calendars and scripts."""
+ area_registry = ar.async_get(hass)
+ entity_registry = er.async_get(hass)
+ device_registry = dr.async_get(hass)
+ interesting_attributes = {
+ "temperature",
+ "current_temperature",
+ "temperature_unit",
+ "brightness",
+ "humidity",
+ "unit_of_measurement",
+ "device_class",
+ "current_position",
+ "percentage",
+ "volume_level",
+ "media_title",
+ "media_artist",
+ "media_album_name",
+ }
+
+ entities: dict[str, dict[str, Any]] = {}
+
+ for state in sorted(hass.states.async_all(), key=attrgetter("name")):
+ if not async_should_expose(hass, assistant, state.entity_id):
+ continue
+
+ # Calendars and scripts have their own tools; skip them here.
+ if state.domain in (CALENDAR_DOMAIN, SCRIPT_DOMAIN):
+ continue
+
+ entity_entry = entity_registry.async_get(state.entity_id)
+ device_entry = (
+ device_registry.async_get(entity_entry.device_id)
+ if entity_entry is not None and entity_entry.device_id is not None
+ else None
+ )
+ names = intent.async_get_entity_aliases(hass, entity_entry, state=state)
+ area_names = []
+
+ if entity_entry is not None:
+ if (
+ entity_entry.area_id is not None
+ and (area_entry := area_registry.async_get_area(entity_entry.area_id))
+ is not None
+ ):
+ # Entity is in area
+ area_names.append(area_entry.name)
+ area_names.extend(sorted(area_entry.aliases))
+ elif device_entry is not None:
+ # Check device area
+ if (
+ device_entry.area_id is not None
+ and (
+ area_entry := area_registry.async_get_area(device_entry.area_id)
+ )
+ is not None
+ ):
+ area_names.append(area_entry.name)
+ area_names.extend(sorted(area_entry.aliases))
+
+ info: dict[str, Any] = {
+ "names": ", ".join(names),
+ "domain": state.domain,
+ }
+
+ if include_state:
+ info["state"] = state.state
+
+ # Format numeric states with configured display precision
+ if state.domain == "sensor":
+ info["state"] = async_rounded_state(hass, state.entity_id, state)
+
+ # Convert timestamp device_class states from UTC to local time
+ if (
+ state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
+ == SensorDeviceClass.TIMESTAMP
+ and state.state
+ ):
+ if (parsed_utc := dt_util.parse_datetime(state.state)) is not None:
+ info["state"] = dt_util.as_local(parsed_utc).isoformat()
+
+ if area_names:
+ info["areas"] = ", ".join(area_names)
+
+ if include_state and (
+ attributes := {
+ str(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
+
+ entities[state.entity_id] = info
+
+ return entities
+
+
+def _live_context_match_error(
+ match_result: intent.MatchTargetsResult,
+ name_filter: str | None,
+ area_filter: str | None,
+ domain_filter: list[str] | None,
+) -> str:
+ """Build an actionable error message for a failed GetLiveContext match."""
+ reason = match_result.no_match_reason
+ if reason is intent.MatchFailedReason.INVALID_AREA:
+ return f"Area '{match_result.no_match_name}' does not exist"
+ if reason is intent.MatchFailedReason.NAME:
+ return f"No exposed entities matched name '{name_filter}'"
+ if reason is intent.MatchFailedReason.AREA:
+ return f"No exposed entities found in area '{area_filter}'"
+ if reason is intent.MatchFailedReason.DOMAIN:
+ domains = ", ".join(domain_filter) if domain_filter else ""
+ return f"No exposed entities found in domain(s): {domains}"
+ return "No entities matched the provided filter"
+
+
+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 = (
+ "Provides real-time information about the"
+ " CURRENT state, value, or mode of devices,"
+ " sensors, entities, or areas. "
+ "Use this tool for: "
+ "1. Answering questions about current"
+ " conditions (e.g., 'Is the light on?'). "
+ "2. As the first step in conditional actions"
+ " (e.g., 'If the weather is rainy, turn off"
+ " sprinklers' requires checking the weather"
+ " first). "
+ "You may filter for devices by name, domain,"
+ " and area, including combining those"
+ " filters. "
+ "Prefer filtering by domain when searching"
+ " for multiple devices of the same type."
+ )
+ parameters = vol.Schema(
+ {
+ vol.Optional(
+ "name",
+ description="Filter entities by name or alias (case-insensitive).",
+ ): cv.string,
+ vol.Optional(
+ "domain",
+ description=(
+ "Filter entities by domain"
+ " (e.g. 'light', 'sensor')."
+ " Accepts a single domain or a list."
+ ),
+ ): vol.Any(cv.string, [cv.string]),
+ vol.Optional(
+ "area",
+ description="Filter entities by area name or alias (case-insensitive).",
+ ): cv.string,
+ }
+ )
+
+ @override
+ async def async_call(
+ self,
+ hass: HomeAssistant,
+ tool_input: ToolInput,
+ llm_context: LLMContext,
+ ) -> JsonObjectType:
+ """Get the current state of exposed entities."""
+ args = self.parameters(tool_input.tool_args)
+ exposed_entities = async_get_exposed_entities(hass, llm_context.assistant)
+
+ if not exposed_entities:
+ return {"success": False, "error": NO_ENTITIES_PROMPT}
+
+ name_filter = args.get("name")
+ area_filter = args.get("area")
+ domain_filter = args.get("domain")
+
+ if isinstance(domain_filter, str):
+ domain_filter = [domain_filter]
+
+ if domain_filter is not None:
+ domain_filter = [
+ normalized_domain
+ for domain in domain_filter
+ if (normalized_domain := domain.strip().lower())
+ ]
+
+ if name_filter or area_filter or domain_filter:
+ exposed_states = [
+ state
+ for entity_id in exposed_entities
+ if (state := hass.states.get(entity_id)) is not None
+ ]
+ match_result = intent.async_match_targets(
+ hass,
+ intent.MatchTargetsConstraints(
+ name=name_filter,
+ area_name=area_filter,
+ domains=domain_filter,
+ # This tool only returns context, so multiple entities
+ # sharing a name (e.g. "AC" in two areas) should all be
+ # returned rather than failing as an ambiguous match.
+ allow_duplicate_names=True,
+ ),
+ states=exposed_states,
+ )
+
+ if not match_result.is_match:
+ return {
+ "success": False,
+ "error": _live_context_match_error(
+ match_result, name_filter, area_filter, domain_filter
+ ),
+ }
+
+ matched_ids = {state.entity_id for state in match_result.states}
+ entities = [
+ info
+ for entity_id, info in exposed_entities.items()
+ if entity_id in matched_ids
+ ]
+ else:
+ entities = list(exposed_entities.values())
+
+ prompt = [
+ "Live Context: An overview of the areas"
+ " and the devices in this smart home:",
+ yaml_util.dump(entities),
+ ]
+ return {
+ "success": True,
+ "result": "\n".join(prompt),
+ }
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return the GetLiveContext tool and the smart home context prompt."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ exposed_entities = async_get_exposed_entities(
+ hass, llm_context.assistant, include_state=False
+ )
+ if exposed_entities:
+ prompt = "\n".join(
+ [
+ DYNAMIC_CONTEXT_PROMPT,
+ "Static Context: An overview of the areas"
+ " and the devices in this smart home:",
+ yaml_util.dump(list(exposed_entities.values())),
+ ]
+ )
+ else:
+ prompt = NO_ENTITIES_PROMPT
+
+ return LLMTools(tools=[GetLiveContextTool()], prompt=prompt)
diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py
index ded640b55a70..b8d1b91b7097 100644
--- a/homeassistant/components/homeassistant/triggers/time.py
+++ b/homeassistant/components/homeassistant/triggers/time.py
@@ -9,7 +9,6 @@ import voluptuous as vol
from homeassistant.components import sensor
from homeassistant.const import (
- ATTR_DEVICE_CLASS,
CONF_AT,
CONF_ENTITY_ID,
CONF_OFFSET,
@@ -18,6 +17,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
WEEKDAYS,
+ EntityStateAttribute,
)
from homeassistant.core import (
CALLBACK_TYPE,
@@ -224,7 +224,7 @@ async def async_attach_trigger( # noqa: C901
)
elif (
new_state.domain == "sensor"
- and new_state.attributes.get(ATTR_DEVICE_CLASS)
+ and new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME)
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
):
diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py
index 01a7d3995344..dac324ea09ac 100644
--- a/homeassistant/components/homee/__init__.py
+++ b/homeassistant/components/homee/__init__.py
@@ -105,10 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
)
if not is_node_present:
_LOGGER.info("Removing device %s", device.name)
- device_registry.async_update_device(
- device_id=device.id,
- remove_config_entry_id=entry.entry_id,
- )
+ device_registry.async_remove_device(device.id)
# Remove device at runtime when node is removed in homee
async def _remove_node_callback(node: HomeeNode, add: bool) -> None:
diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py
index 84ad604667de..b2f853dc792f 100644
--- a/homeassistant/components/homee/cover.py
+++ b/homeassistant/components/homee/cover.py
@@ -1,6 +1,6 @@
"""The homee cover platform."""
-from enum import Enum
+from enum import IntEnum
import logging
from typing import TYPE_CHECKING, Any, cast, override
@@ -38,22 +38,22 @@ IS_CLOSED_ATTRIBUTES = [
]
-class HomeeCoverState(float, Enum):
+class HomeeCoverState(IntEnum):
"""Open/closed states for covers in homee."""
- OPEN = 0.0
- CLOSED = 1.0
- STOPPED = 2.0
- OPENING = 3.0
- CLOSING = 4.0
+ OPEN = 0
+ CLOSED = 1
+ STOPPED = 2
+ OPENING = 3
+ CLOSING = 4
-class HomeeSlatState(float, Enum):
+class HomeeSlatState(IntEnum):
"""Slat states for covers in homee."""
- STOPPED = 0.0
- CLOSED = 1.0
- OPEN = 2.0
+ STOPPED = 0
+ CLOSED = 1
+ OPEN = 2
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json
index 54e1c3efbc88..d43c504f5018 100644
--- a/homeassistant/components/homee/manifest.json
+++ b/homeassistant/components/homee/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "silver",
- "requirements": ["pyHomee==1.4.1"],
+ "requirements": ["pyHomee==1.4.2"],
"zeroconf": [
{
"name": "homee-*",
diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py
index 0626071eeaff..55f2ae2d3bc8 100644
--- a/homeassistant/components/homekit/__init__.py
+++ b/homeassistant/components/homekit/__init__.py
@@ -88,6 +88,7 @@ from . import ( # noqa: F401
type_cameras,
type_covers,
type_fans,
+ type_heater_coolers,
type_humidifiers,
type_lights,
type_locks,
@@ -98,7 +99,13 @@ from . import ( # noqa: F401
type_switches,
type_thermostats,
)
-from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory
+from .accessories import (
+ HomeAccessory,
+ HomeBridge,
+ HomeDriver,
+ async_resolve_accessory_type,
+ get_accessory,
+)
from .aidmanager import AccessoryAidStorage
from .const import (
ATTR_INTEGRATION,
@@ -572,6 +579,10 @@ class HomeKit:
self.bridge: HomeBridge | None = None
self._reset_lock = asyncio.Lock()
self._cancel_reload_dispatcher: CALLBACK_TYPE | None = None
+ # True while running the first ever start of this entry (no
+ # persisted pairing state yet); accessory mode uses it to tell a
+ # brand new entry from one that predates the HeaterCooler.
+ self._first_ever_start = False
def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> bool:
"""Set up bridge and accessory driver.
@@ -669,8 +680,10 @@ class HomeKit:
removed: list[str] = []
acc: HomeAccessory | None
for entity_id in entity_ids:
- aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
- if aid not in self.bridge.accessories:
+ # A lookup must not allocate; an allocation marks the entity as
+ # previously bridged, which would suppress the automatic routing.
+ aid = self.aid_storage.get_allocated_aid_for_entity_id(entity_id)
+ if aid is None or aid not in self.bridge.accessories:
continue
if acc := self.async_remove_bridge_accessory(aid):
self._async_shutdown_accessory(acc)
@@ -753,8 +766,14 @@ class HomeKit:
assert self.aid_storage is not None
assert self.bridge is not None
- aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id)
conf = self._config.get(state.entity_id, {}).copy()
+ # Must run before the aid is allocated below so a never bridged
+ # entity is still recognizable as new.
+ pending_type = async_resolve_accessory_type(
+ self.aid_storage, state, conf, allow_auto=True
+ )
+ newly_allocated = not self.aid_storage.entity_is_allocated(state.entity_id)
+ aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id)
# If an accessory cannot be created or added due to an exception
# of any kind (usually in pyhap) it should not prevent
# the rest of the accessories from being created
@@ -762,11 +781,19 @@ class HomeKit:
acc = get_accessory(self.hass, self.driver, state, aid, conf)
if acc is not None:
self.bridge.add_accessory(acc)
+ if pending_type:
+ self.aid_storage.async_set_accessory_type(
+ state.entity_id, pending_type
+ )
return acc
except Exception:
_LOGGER.exception(
"Failed to create a HomeKit accessory for %s", state.entity_id
)
+ if newly_allocated:
+ # A failed first attempt must not classify the entity as
+ # existing on the next try.
+ self.aid_storage.async_delete_aid_for_entity_id(state.entity_id)
return None
def _would_exceed_max_devices(self, name: str | None) -> bool:
@@ -882,6 +909,7 @@ class HomeKit:
self.setup, async_zc_instance, uuid
)
assert self.driver is not None
+ self._first_ever_start = not loaded_from_disk
if not await self._async_create_accessories():
return
@@ -894,6 +922,9 @@ class HomeKit:
# need to make sure its persisted to disk.
async with self.hass.data[PERSIST_LOCK_DATA]:
await self.hass.async_add_executor_job(self.driver.persist)
+ # The pairing state is persisted now, so later reloads treat the
+ # entry as existing.
+ self._first_ever_start = False
self.status = STATUS_RUNNING
if self.driver.state.paired:
@@ -1000,6 +1031,13 @@ class HomeKit:
return None
state = entity_states[0]
conf = self._config.get(state.entity_id, {}).copy()
+ # Accessory mode has no aid allocation to tell new from existing,
+ # so only a brand new pairing picks its type automatically; anything
+ # else keeps its current accessory.
+ assert self.aid_storage is not None
+ pending_type = async_resolve_accessory_type(
+ self.aid_storage, state, conf, allow_auto=self._first_ever_start
+ )
acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf)
if acc is None:
_LOGGER.error(
@@ -1007,6 +1045,9 @@ class HomeKit:
self._name,
self._filter.config,
)
+ return None
+ if pending_type:
+ self.aid_storage.async_set_accessory_type(state.entity_id, pending_type)
return acc
async def _async_create_bridge_accessory(
diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py
index 186be4ce8c0c..88cd6b276dbf 100644
--- a/homeassistant/components/homekit/accessories.py
+++ b/homeassistant/components/homekit/accessories.py
@@ -12,6 +12,10 @@ from pyhap.iid_manager import IIDManager
from pyhap.service import Service
from pyhap.util import callback as pyhap_callback
+from homeassistant.components.climate import (
+ DOMAIN as CLIMATE_DOMAIN,
+ ClimateEntityFeature,
+)
from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature
from homeassistant.components.lawn_mower import LawnMowerEntityFeature
from homeassistant.components.media_player import MediaPlayerDeviceClass
@@ -56,6 +60,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util.decorator import Registry
+from .aidmanager import AccessoryAidStorage
+from .climate_util import (
+ get_fan_modes_and_speeds,
+ get_swing_on_mode,
+ has_swing_off_mode,
+)
from .const import (
ATTR_DISPLAY_NAME,
ATTR_INTEGRATION,
@@ -87,10 +97,12 @@ from .const import (
TYPE_AIR_PURIFIER,
TYPE_FAN,
TYPE_FAUCET,
+ TYPE_HEATER_COOLER,
TYPE_OUTLET,
TYPE_SHOWER,
TYPE_SPRINKLER,
TYPE_SWITCH,
+ TYPE_THERMOSTAT,
TYPE_VALVE,
)
from .iidmanager import AccessoryIIDStorage
@@ -117,6 +129,10 @@ FAN_TYPES = {
TYPE_AIR_PURIFIER: "AirPurifier",
TYPE_FAN: "Fan",
}
+CLIMATE_TYPES = {
+ TYPE_HEATER_COOLER: "HeaterCooler",
+ TYPE_THERMOSTAT: "Thermostat",
+}
TYPES: Registry[str, type[HomeAccessory]] = Registry()
RELOAD_ON_CHANGE_ATTRS = (
@@ -126,6 +142,102 @@ RELOAD_ON_CHANGE_ATTRS = (
)
+def climate_controls_target_humidity(state: State) -> bool:
+ """Return True when a climate entity exposes a humidity setpoint.
+
+ HeaterCooler cannot control a humidity setpoint; entities that
+ expose one (e.g. econet) stay on the Thermostat, which can.
+ """
+ features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ return bool(features & ClimateEntityFeature.TARGET_HUMIDITY)
+
+
+def climate_supports_heater_cooler(state: State) -> bool:
+ """Return True when a climate entity fits the HeaterCooler accessory."""
+ attributes = state.attributes
+ features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ # Timing fan modes like auto or circulate do not count as speeds.
+ has_fan = bool(features & ClimateEntityFeature.FAN_MODE) and (
+ len(get_fan_modes_and_speeds(attributes)[1]) >= 2
+ )
+ # The binary swing control writes the off mode back, so automatic
+ # routing requires the entity to advertise one.
+ has_swing = bool(features & ClimateEntityFeature.SWING_MODE) and (
+ get_swing_on_mode(attributes) is not None and has_swing_off_mode(attributes)
+ )
+ return (has_fan or has_swing) and not (
+ features & ClimateEntityFeature.TARGET_HUMIDITY
+ )
+
+
+@ha_callback
+def async_resolve_accessory_type(
+ aid_storage: AccessoryAidStorage,
+ state: State,
+ conf: dict[str, Any],
+ *,
+ allow_auto: bool,
+) -> str | None:
+ """Resolve which accessory an entity uses into conf.
+
+ Some domains can be represented by more than one HomeKit accessory;
+ climate is the only such domain today. Returns the accessory type the
+ caller must record with async_set_accessory_type once the accessory is
+ successfully created, so a failed creation is not sticky across
+ restarts; a stored routing the entity can no longer support is dropped
+ immediately instead.
+ """
+ if state.domain != CLIMATE_DOMAIN:
+ return None
+ return _async_resolve_climate_type(aid_storage, state, conf, allow_auto=allow_auto)
+
+
+@ha_callback
+def _async_resolve_climate_type(
+ aid_storage: AccessoryAidStorage,
+ state: State,
+ conf: dict[str, Any],
+ *,
+ allow_auto: bool,
+) -> str | None:
+ """Resolve which accessory a climate entity uses into conf.
+
+ An explicit type in the entity config always wins, even for entities
+ with a humidity setpoint, and updates the stored routing, so switching
+ back to automatic keeps the accessory the entity already uses. In
+ bridge mode an entity that has never been bridged gets the HeaterCooler
+ when capable. Anything else keeps the Thermostat; the accessory type
+ can be changed at any time from the bridge options.
+ """
+ entity_id = state.entity_id
+ if climate_type := conf.get(CONF_TYPE):
+ # The explicit type is recorded by the caller like the automatic
+ # one, so every path that sets a type defers persistence until
+ # the accessory exists.
+ return cast(str, climate_type)
+ stored_type = aid_storage.get_accessory_type(entity_id)
+ if stored_type == TYPE_HEATER_COOLER:
+ if not climate_controls_target_humidity(state):
+ conf[CONF_TYPE] = TYPE_HEATER_COOLER
+ return None
+ # A humidity setpoint gained since the choice was stored cannot
+ # be represented by the HeaterCooler, so the routing is dropped.
+ aid_storage.async_set_accessory_type(entity_id, None)
+ if not climate_supports_heater_cooler(state):
+ return None
+ if (
+ stored_type is None
+ and allow_auto
+ and not aid_storage.entity_is_allocated(entity_id)
+ ):
+ # A stored Thermostat choice survives even when the entity looks
+ # new again, like an accessory mode pairing reset, so Automatic
+ # keeps the accessory the entity already uses.
+ conf[CONF_TYPE] = TYPE_HEATER_COOLER
+ return TYPE_HEATER_COOLER
+ return None
+
+
def get_accessory( # noqa: C901
hass: HomeAssistant, driver: HomeDriver, state: State, aid: int | None, config: dict
) -> HomeAccessory | None:
@@ -151,7 +263,8 @@ def get_accessory( # noqa: C901
a_type = "BinarySensor"
elif state.domain == "climate":
- a_type = "Thermostat"
+ # The type is resolved by the bridge before the accessory is created.
+ a_type = CLIMATE_TYPES[config.get(CONF_TYPE, TYPE_THERMOSTAT)]
elif state.domain == "cover":
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
@@ -637,6 +750,25 @@ class HomeAccessory(Accessory): # type: ignore[misc]
value: Any | None = None,
) -> None:
"""Fire event and call service for changes from HomeKit."""
+ self.hass.async_create_task(
+ self.async_call_service_and_wait(domain, service, service_data, value),
+ eager_start=True,
+ )
+
+ async def async_call_service_and_wait(
+ self,
+ domain: str,
+ service: str,
+ service_data: dict[str, Any],
+ value: Any | None = None,
+ ) -> bool:
+ """Fire event and call service, returning True when it succeeded.
+
+ blocking=True so the handler's exception reaches us (the
+ non-blocking path swallows it); on failure we resync so pyhap's
+ optimistic target characteristic doesn't strand the tile on the
+ requested action.
+ """
event_data = {
ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id),
ATTR_DISPLAY_NAME: self.display_name,
@@ -647,36 +779,40 @@ class HomeAccessory(Accessory): # type: ignore[misc]
self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data, context=context)
- async def _call() -> None:
- # blocking=True so the handler's exception reaches us (the
- # non-blocking path swallows it); on failure we resync so pyhap's
- # optimistic target characteristic doesn't strand the tile on the
- # requested action.
- try:
- await self.hass.services.async_call(
- domain, service, service_data, blocking=True, context=context
- )
- except HomeAssistantError as err:
- _LOGGER.warning(
- "%s: %s.%s failed (%s); re-syncing HomeKit state",
- self.entity_id,
- domain,
- service,
- err,
- )
- except Exception:
- _LOGGER.exception(
- "%s: %s.%s raised unexpectedly; re-syncing HomeKit state",
- self.entity_id,
- domain,
- service,
- )
- else:
- return
+ try:
+ await self.hass.services.async_call(
+ domain, service, service_data, blocking=True, context=context
+ )
+ except HomeAssistantError as err:
+ _LOGGER.warning(
+ "%s: %s.%s failed (%s); re-syncing HomeKit state",
+ self.entity_id,
+ domain,
+ service,
+ err,
+ )
+ except Exception:
+ _LOGGER.exception(
+ "%s: %s.%s raised unexpectedly; re-syncing HomeKit state",
+ self.entity_id,
+ domain,
+ service,
+ )
+ else:
+ return True
+ # This coroutine often runs fire-and-forget, so failures must be
+ # logged here instead of by the loop's default task handler.
+ try:
if (state := self.hass.states.get(self.entity_id)) is not None:
self.async_update_state(state)
-
- self.hass.async_create_task(_call(), eager_start=True)
+ else:
+ _LOGGER.debug(
+ "%s: cannot re-sync HomeKit state; entity has no state",
+ self.entity_id,
+ )
+ except Exception:
+ _LOGGER.exception("%s: re-syncing HomeKit state failed", self.entity_id)
+ return False
@ha_callback
def async_reload(self) -> None:
diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py
index c76232f65f91..abcfb03575cd 100644
--- a/homeassistant/components/homekit/aidmanager.py
+++ b/homeassistant/components/homekit/aidmanager.py
@@ -25,6 +25,7 @@ AID_MANAGER_SAVE_DELAY = 2
ALLOCATIONS_KEY = "allocations"
UNIQUE_IDS_KEY = "unique_ids"
+ACCESSORY_TYPES_KEY = "accessory_types"
INVALID_AIDS = (0, 1)
@@ -69,6 +70,7 @@ class AccessoryAidStorage:
self.hass = hass
self.allocations: dict[str, int] = {}
self.allocated_aids: set[int] = set()
+ self.accessory_types: dict[str, str] = {}
self._entry_id = entry_id
self.store: Store | None = None
self._entity_registry = er.async_get(hass)
@@ -84,6 +86,59 @@ class AccessoryAidStorage:
assert isinstance(raw_storage, dict)
self.allocations = raw_storage.get(ALLOCATIONS_KEY, {})
self.allocated_aids = set(self.allocations.values())
+ self.accessory_types = raw_storage.get(ACCESSORY_TYPES_KEY, {})
+
+ def _stable_storage_keys(self, entity_id: str) -> tuple[str, ...]:
+ """Return the keys the entity's stable identity can resolve to.
+
+ The preferred key comes first, matching the aid allocation
+ preference for the system unique id over the entity id.
+ """
+ if not (entry := self._entity_registry.async_get(entity_id)):
+ return (entity_id,)
+ keys = [get_system_unique_id(entry, entry.unique_id)]
+ if previous_unique_id := entry.previous_unique_id:
+ keys.append(get_system_unique_id(entry, previous_unique_id))
+ keys.append(entity_id)
+ return tuple(keys)
+
+ @callback
+ def async_set_accessory_type(
+ self, entity_id: str, accessory_type: str | None
+ ) -> None:
+ """Persist the accessory type an entity resolved to, None clears it.
+
+ The choice is stored by the same stable identity as the aid
+ allocation, so it survives entity id renames and unique id changes.
+ """
+ if accessory_type == self.get_accessory_type(entity_id):
+ return
+ types = self.accessory_types
+ keys = self._stable_storage_keys(entity_id)
+ for key in keys:
+ types.pop(key, None)
+ if accessory_type is not None:
+ types[keys[0]] = accessory_type
+ self.async_schedule_save()
+
+ @callback
+ def get_accessory_type(self, entity_id: str) -> str | None:
+ """Return the stored accessory type for the entity, if any.
+
+ A type found under an outdated identity moves to the current one
+ and schedules a save, since only the latest previous unique id
+ stays resolvable; the read is loop bound because of that.
+ """
+ types = self.accessory_types
+ keys = self._stable_storage_keys(entity_id)
+ for key in keys:
+ if (accessory_type := types.get(key)) is not None:
+ if key != keys[0]:
+ del types[key]
+ types[keys[0]] = accessory_type
+ self.async_schedule_save()
+ return accessory_type
+ return None
def get_or_allocate_aid_for_entity_id(self, entity_id: str) -> int:
"""Generate a stable aid for an entity id."""
@@ -94,6 +149,15 @@ class AccessoryAidStorage:
self._migrate_unique_id_aid_assignment_if_needed(sys_unique_id, entry)
return self.get_or_allocate_aid(sys_unique_id, entity_id)
+ def entity_is_allocated(self, entity_id: str) -> bool:
+ """Return True when the entity already has an allocated aid.
+
+ Checks every key get_or_allocate_aid_for_entity_id could resolve to,
+ without allocating, so callers can tell a previously bridged entity
+ from a new one.
+ """
+ return self.get_allocated_aid_for_entity_id(entity_id) is not None
+
def _migrate_unique_id_aid_assignment_if_needed(
self, sys_unique_id: str, entry: er.RegistryEntry
) -> None:
@@ -129,6 +193,24 @@ class AccessoryAidStorage:
f"Unable to generate unique aid allocation for {entity_id} [{unique_id}]"
)
+ def get_allocated_aid_for_entity_id(self, entity_id: str) -> int | None:
+ """Return the entity's allocated aid without allocating one."""
+ allocations = self.allocations
+ return next(
+ (
+ allocations[key]
+ for key in self._stable_storage_keys(entity_id)
+ if key in allocations
+ ),
+ None,
+ )
+
+ @callback
+ def async_delete_aid_for_entity_id(self, entity_id: str) -> None:
+ """Remove the aid allocation for an entity."""
+ for key in self._stable_storage_keys(entity_id):
+ self.delete_aid(key)
+
def delete_aid(self, storage_key: str) -> None:
"""Delete an aid allocation."""
if storage_key not in self.allocations:
@@ -150,6 +232,11 @@ class AccessoryAidStorage:
return await self.store.async_save(self._data_to_save())
@callback
- def _data_to_save(self) -> dict[str, dict[str, int]]:
+ def _data_to_save(self) -> dict[str, dict[str, int] | dict[str, str]]:
"""Return data of entity map to store in a file."""
- return {ALLOCATIONS_KEY: self.allocations}
+ data: dict[str, dict[str, int] | dict[str, str]] = {
+ ALLOCATIONS_KEY: self.allocations
+ }
+ if self.accessory_types:
+ data[ACCESSORY_TYPES_KEY] = self.accessory_types
+ return data
diff --git a/homeassistant/components/homekit/climate_base.py b/homeassistant/components/homekit/climate_base.py
new file mode 100644
index 000000000000..401f5409d0d8
--- /dev/null
+++ b/homeassistant/components/homekit/climate_base.py
@@ -0,0 +1,383 @@
+"""Base class shared by the climate accessory types."""
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from pyhap.characteristic import Characteristic
+from pyhap.const import CATEGORY_THERMOSTAT
+from pyhap.service import Service
+
+from homeassistant.components.climate import (
+ ATTR_CURRENT_TEMPERATURE,
+ ATTR_FAN_MODE,
+ ATTR_FAN_MODES,
+ ATTR_HVAC_ACTION,
+ ATTR_HVAC_MODES,
+ ATTR_MAX_TEMP,
+ ATTR_MIN_TEMP,
+ ATTR_SWING_MODE,
+ ATTR_SWING_MODES,
+ ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW,
+ DEFAULT_MAX_TEMP,
+ DEFAULT_MIN_TEMP,
+ DOMAIN as CLIMATE_DOMAIN,
+ FAN_AUTO,
+ FAN_OFF,
+ FAN_ON,
+ SERVICE_SET_FAN_MODE,
+ SERVICE_SET_SWING_MODE,
+ SWING_OFF,
+ ClimateEntityFeature,
+ HVACAction,
+ HVACMode,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_SUPPORTED_FEATURES,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+)
+from homeassistant.core import State, callback
+from homeassistant.util.percentage import percentage_to_ordered_list_item
+
+from .accessories import HomeAccessory
+from .climate_util import (
+ fan_mode_to_speed,
+ fan_speed_to_mode,
+ get_fan_modes_and_speeds,
+ get_swing_off_mode,
+ get_swing_on_mode,
+ get_temperature_range_from_state,
+ has_swing_off_mode,
+ is_swing_on,
+ resolve_target_temp_range,
+ temperature_attribute_to_homekit,
+)
+from .const import (
+ CHAR_ACTIVE,
+ CHAR_CURRENT_FAN_STATE,
+ CHAR_CURRENT_TEMPERATURE,
+ CHAR_ROTATION_SPEED,
+ CHAR_SWING_MODE,
+ CHAR_TARGET_FAN_STATE,
+ PROP_MAX_VALUE,
+ PROP_MIN_STEP,
+ PROP_MIN_VALUE,
+ SERV_FANV2,
+)
+from .util import temperature_to_homekit, temperature_to_states
+
+_LOGGER = logging.getLogger(__name__)
+
+FAN_STATE_INACTIVE = 0
+FAN_STATE_IDLE = 1
+FAN_STATE_ACTIVE = 2
+
+# States in which a climate entity is inactive rather than idle
+CLIMATE_INACTIVE_STATES = frozenset({HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN})
+
+HC_HASS_TO_HOMEKIT_FAN_STATE = {
+ HVACAction.OFF: FAN_STATE_INACTIVE,
+ HVACAction.IDLE: FAN_STATE_IDLE,
+ HVACAction.HEATING: FAN_STATE_ACTIVE,
+ HVACAction.COOLING: FAN_STATE_ACTIVE,
+ HVACAction.DRYING: FAN_STATE_ACTIVE,
+ HVACAction.FAN: FAN_STATE_ACTIVE,
+ HVACAction.PREHEATING: FAN_STATE_IDLE,
+ HVACAction.DEFROSTING: FAN_STATE_IDLE,
+}
+
+
+class HomeKitClimateAccessory(HomeAccessory):
+ """Base class for the Thermostat and HeaterCooler accessories."""
+
+ # Configured by subclasses only when the entity exposes the mode.
+ char_speed: Characteristic
+ char_swing: Characteristic
+
+ char_current_temp: Characteristic
+
+ # Configured by _configure_fan_service when fan_chars is non-empty.
+ char_fan_active: Characteristic
+ char_target_fan_state: Characteristic
+ char_current_fan_state: Characteristic
+
+ def __init__(self, *args: Any) -> None:
+ """Initialize the shared climate accessory state."""
+ super().__init__(*args, category=CATEGORY_THERMOSTAT)
+ self._unit = self.hass.config.units.temperature_unit
+
+ state = self.hass.states.get(self.entity_id)
+ assert state
+ attributes = state.attributes
+ features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ # ``fan_modes`` maps lowercased names to their original casing;
+ # ``ordered_fan_speeds`` holds the predefined speeds in HomeKit order.
+ self.fan_modes: dict[str, str] = {}
+ self.ordered_fan_speeds: list[str] = []
+ if features & ClimateEntityFeature.FAN_MODE:
+ self.fan_modes, self.ordered_fan_speeds = get_fan_modes_and_speeds(
+ attributes
+ )
+
+ self.swing_on_mode: str | None = None
+ self.swing_off_mode: str = SWING_OFF
+ # The binary swing toggle writes the off mode back, so it is only
+ # usable when the entity advertises one.
+ if features & ClimateEntityFeature.SWING_MODE and has_swing_off_mode(
+ attributes
+ ):
+ self.swing_on_mode = get_swing_on_mode(attributes)
+ self.swing_off_mode = get_swing_off_mode(attributes)
+
+ # Characteristics the subclass places on a linked fan service; which
+ # ones, if any, is the subclass's policy.
+ self.fan_chars: list[str] = []
+
+ # These attributes drive the characteristic set and valid values, so
+ # reload the accessory when any of them change.
+ self._reload_on_change_attrs.extend(
+ (
+ ATTR_MIN_TEMP,
+ ATTR_MAX_TEMP,
+ ATTR_FAN_MODES,
+ ATTR_SWING_MODES,
+ ATTR_HVAC_MODES,
+ )
+ )
+
+ def get_temperature_range(self, state: State) -> tuple[float, float]:
+ """Return the min and max temperature range."""
+ return get_temperature_range_from_state(
+ state, self._unit, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP
+ )
+
+ def _configure_current_temperature_char(self, serv: Service) -> None:
+ """Configure the shared current temperature characteristic."""
+ self.char_current_temp = serv.configure_char(
+ CHAR_CURRENT_TEMPERATURE, value=21.0
+ )
+
+ def _configure_target_mode_char(
+ self,
+ serv: Service,
+ char_name: str,
+ value: int,
+ valid_values: dict[HVACMode, int],
+ ) -> Characteristic:
+ """Configure a target mode characteristic scoped to the supported modes.
+
+ The value must be set before ``valid_values`` because pyhap applies the
+ valid values first and would reject a default outside that set.
+ """
+ char = serv.configure_char(char_name, value=value)
+ char.override_properties(valid_values=valid_values)
+ char.allow_invalid_client_values = True
+ return char
+
+ def _reject_char_write(self, char: Characteristic, value: Any) -> None:
+ """Flip a characteristic back after rejecting a client write."""
+ char.value = value
+ char.notify()
+
+ def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None:
+ """Send a climate write from a characteristic setter.
+
+ Subclasses can override this to serialize their writes.
+ """
+ self.async_call_service(
+ CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params}
+ )
+
+ def _update_temperature_char(
+ self, char: Characteristic, state: State, attr: str
+ ) -> None:
+ """Set a temperature characteristic from a state attribute, if present."""
+ if (
+ value := temperature_attribute_to_homekit(state, attr, self._unit)
+ ) is not None:
+ char.set_value(value)
+
+ def _update_current_temperature_char(self, state: State) -> None:
+ """Update the current temperature characteristic from the entity state."""
+ self._update_temperature_char(
+ self.char_current_temp, state, ATTR_CURRENT_TEMPERATURE
+ )
+
+ def _dual_setpoint_params(
+ self,
+ cool_char: Characteristic,
+ heat_char: Characteristic,
+ new_high: float | None,
+ new_low: float | None,
+ ) -> dict[str, float]:
+ """Return an ordered high/low target temperature pair for a range write.
+
+ Fills the unchanged side from the current characteristic value and
+ enforces the deadband, so the entity always gets a consistent pair.
+ """
+ high, low = resolve_target_temp_range(
+ cool_char.value,
+ heat_char.value,
+ new_high,
+ new_low,
+ cool_char.properties[PROP_MIN_VALUE],
+ cool_char.properties[PROP_MAX_VALUE],
+ )
+ return {
+ ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high),
+ ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low),
+ }
+
+ def _temperature_to_homekit(self, temp: float) -> float:
+ """Convert a temperature in the entity's unit to the HomeKit unit."""
+ return temperature_to_homekit(temp, self._unit)
+
+ def _temperature_to_states(self, temp: float) -> float:
+ """Convert a temperature in the HomeKit unit to the entity's unit."""
+ return temperature_to_states(temp, self._unit)
+
+ def _fan_speed_params(self, speed: int) -> dict[str, Any] | None:
+ """Return the set_fan_mode data for a HomeKit rotation speed."""
+ _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed)
+ if not self.ordered_fan_speeds or not 0 < speed <= 100:
+ return None
+ mode = fan_speed_to_mode(self.ordered_fan_speeds, self.fan_modes, speed)
+ return {ATTR_FAN_MODE: mode}
+
+ def _set_fan_speed(self, speed: int) -> None:
+ """Send the climate fan mode for a HomeKit rotation speed."""
+ if (params := self._fan_speed_params(speed)) is not None:
+ self._dispatch_climate_write(SERVICE_SET_FAN_MODE, params)
+
+ def _swing_mode_params(self, swing_on: int) -> dict[str, Any] | None:
+ """Return the set_swing_mode data for a HomeKit swing toggle."""
+ if self.swing_on_mode is None:
+ return None
+ _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on)
+ return {
+ ATTR_SWING_MODE: self.swing_on_mode if swing_on else self.swing_off_mode
+ }
+
+ def _set_swing_mode(self, swing_on: int) -> None:
+ """Send the climate swing mode for a HomeKit swing toggle."""
+ if (params := self._swing_mode_params(swing_on)) is not None:
+ self._dispatch_climate_write(SERVICE_SET_SWING_MODE, params)
+
+ def _update_fan_speed_char(self, attributes: Mapping[str, Any]) -> None:
+ """Update the rotation speed characteristic from the current fan mode."""
+ # Modes with no predefined speed (e.g. fan auto) keep the last value;
+ # HomeKit's slider has no position to represent them.
+ if (
+ self.ordered_fan_speeds
+ and (
+ speed := fan_mode_to_speed(
+ self.ordered_fan_speeds, attributes.get(ATTR_FAN_MODE)
+ )
+ )
+ is not None
+ ):
+ self.char_speed.set_value(speed)
+
+ def _update_swing_char(self, attributes: Mapping[str, Any]) -> None:
+ """Update the swing characteristic from the current swing mode."""
+ # An absent swing mode keeps the last value; there is nothing to show.
+ if self.swing_on_mode is not None and (
+ swing_mode := attributes.get(ATTR_SWING_MODE)
+ ):
+ self.char_swing.set_value(1 if is_swing_on(swing_mode) else 0)
+
+ def _configure_fan_service(self, primary_serv: Service) -> None:
+ """Create a linked fan service for the chars in ``fan_chars``."""
+ serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars)
+ primary_serv.add_linked_service(serv_fan)
+ self.char_fan_active = serv_fan.configure_char(
+ CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active
+ )
+ if CHAR_SWING_MODE in self.fan_chars:
+ self.char_swing = serv_fan.configure_char(
+ CHAR_SWING_MODE,
+ value=0,
+ setter_callback=self._set_swing_mode,
+ )
+ self.char_swing.display_name = "Swing Mode"
+ if CHAR_ROTATION_SPEED in self.fan_chars:
+ self.char_speed = serv_fan.configure_char(
+ CHAR_ROTATION_SPEED,
+ value=100,
+ properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)},
+ setter_callback=self._set_fan_speed,
+ )
+ self.char_speed.display_name = "Fan Mode"
+ if CHAR_CURRENT_FAN_STATE in self.fan_chars:
+ self.char_current_fan_state = serv_fan.configure_char(
+ CHAR_CURRENT_FAN_STATE,
+ value=0,
+ )
+ self.char_current_fan_state.display_name = "Fan State"
+ if CHAR_TARGET_FAN_STATE in self.fan_chars:
+ self.char_target_fan_state = serv_fan.configure_char(
+ CHAR_TARGET_FAN_STATE,
+ value=0,
+ setter_callback=self._set_fan_auto,
+ )
+ self.char_target_fan_state.display_name = "Fan Auto"
+
+ def _get_on_mode(self) -> str:
+ """Return the fan mode to use when leaving auto or turning the fan on."""
+ if self.ordered_fan_speeds:
+ speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50)
+ return self.fan_modes[speed_key]
+ return self.fan_modes[FAN_ON]
+
+ def _set_fan_active(self, active: int) -> None:
+ """Send the climate fan mode for a HomeKit fan active toggle."""
+ _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active)
+ if FAN_OFF not in self.fan_modes:
+ _LOGGER.debug(
+ "%s: Fan does not support off, resetting to on", self.entity_id
+ )
+ self._reject_char_write(self.char_fan_active, 1)
+ return
+ mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF]
+ self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode})
+
+ def _set_fan_auto(self, auto: int) -> None:
+ """Send the climate fan mode for a HomeKit fan auto toggle.
+
+ Subclasses must only add CHAR_TARGET_FAN_STATE to ``fan_chars`` when
+ FAN_AUTO is in ``fan_modes``; this setter assumes the mode exists.
+ """
+ _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto)
+ mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode()
+ self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode})
+
+ @callback
+ def _async_update_fan_service(self, new_state: State) -> None:
+ """Update the linked fan service from the entity state."""
+ attributes = new_state.attributes
+
+ self._update_swing_char(attributes)
+ self._update_fan_speed_char(attributes)
+
+ fan_mode = attributes.get(ATTR_FAN_MODE)
+ fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None
+ if CHAR_TARGET_FAN_STATE in self.fan_chars:
+ self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0)
+
+ if CHAR_CURRENT_FAN_STATE in self.fan_chars and (
+ hvac_action := attributes.get(ATTR_HVAC_ACTION)
+ ):
+ self.char_current_fan_state.set_value(
+ HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action]
+ )
+
+ self.char_fan_active.set_value(
+ int(
+ new_state.state not in CLIMATE_INACTIVE_STATES
+ and fan_mode_lower != FAN_OFF
+ )
+ )
diff --git a/homeassistant/components/homekit/climate_util.py b/homeassistant/components/homekit/climate_util.py
new file mode 100644
index 000000000000..92a5bfce06b8
--- /dev/null
+++ b/homeassistant/components/homekit/climate_util.py
@@ -0,0 +1,205 @@
+"""Shared fan, swing, and temperature helpers for the climate accessory types."""
+
+from collections.abc import Iterable
+import math
+from typing import Any
+
+from homeassistant.components.climate import (
+ ATTR_FAN_MODES,
+ ATTR_MAX_TEMP,
+ ATTR_MIN_TEMP,
+ ATTR_SWING_MODES,
+ FAN_HIGH,
+ FAN_LOW,
+ FAN_MEDIUM,
+ FAN_MIDDLE,
+ SWING_BOTH,
+ SWING_HORIZONTAL,
+ SWING_OFF,
+ SWING_ON,
+ SWING_VERTICAL,
+)
+from homeassistant.core import State
+from homeassistant.util.percentage import (
+ ordered_list_item_to_percentage,
+ percentage_to_ordered_list_item,
+)
+
+from .util import get_min_max, temperature_to_homekit
+
+ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH]
+PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS)
+SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL]
+PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER)
+
+# Minimum gap kept between the low and high set points of a range.
+HEAT_COOL_DEADBAND = 5
+
+
+def _lower_to_original(modes: Iterable[Any]) -> dict[str, str]:
+ """Map each string mode to its original casing, keyed by the lowercase form."""
+ return {mode.lower(): mode for mode in modes if isinstance(mode, str)}
+
+
+def get_fan_modes_and_speeds(
+ attributes: dict[str, Any],
+) -> tuple[dict[str, str], list[str]]:
+ """Return the fan modes and ordered predefined speeds for a climate entity.
+
+ ``fan_modes`` maps each lowercased fan mode to its original casing.
+ ``ordered_fan_speeds`` is the subset of predefined speeds the entity
+ exposes, in HomeKit rotation-speed order; it is empty when the entity only
+ advertises custom fan mode names.
+ """
+ fan_modes = _lower_to_original(attributes.get(ATTR_FAN_MODES) or [])
+ ordered_fan_speeds: list[str] = []
+ if PRE_DEFINED_FAN_MODES.intersection(fan_modes):
+ ordered_fan_speeds = [
+ speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
+ ]
+ return fan_modes, ordered_fan_speeds
+
+
+def get_swing_on_mode(attributes: dict[str, Any]) -> str | None:
+ """Return the preferred swing-on mode for a climate entity, if any.
+
+ The match is case insensitive and the entity's original casing is
+ returned so it can be sent back to the service. Returns ``None`` when the
+ entity exposes no predefined swing modes.
+ """
+ if not (swing_modes := attributes.get(ATTR_SWING_MODES)):
+ return None
+ lower_to_original = _lower_to_original(swing_modes)
+ return next(
+ (
+ lower_to_original[swing_mode]
+ for swing_mode in SWING_MODE_PREFERRED_ORDER
+ if swing_mode in lower_to_original
+ ),
+ None,
+ )
+
+
+def get_swing_off_mode(attributes: dict[str, Any]) -> str:
+ """Return the entity's off swing mode, preserving its original casing."""
+ swing_modes = attributes.get(ATTR_SWING_MODES) or []
+ return _lower_to_original(swing_modes).get(SWING_OFF, SWING_OFF)
+
+
+def has_swing_off_mode(attributes: dict[str, Any]) -> bool:
+ """Return whether the entity advertises a swing off mode."""
+ swing_modes = attributes.get(ATTR_SWING_MODES) or []
+ return SWING_OFF in _lower_to_original(swing_modes)
+
+
+def fan_speed_to_mode(
+ ordered_fan_speeds: list[str], fan_modes: dict[str, str], speed: int
+) -> str:
+ """Return the climate fan mode for a HomeKit rotation speed percentage.
+
+ The percentage is offset by one so the lowest slider step maps to the
+ first ordered speed.
+ """
+ speed_key = percentage_to_ordered_list_item(ordered_fan_speeds, speed - 1)
+ return fan_modes[speed_key]
+
+
+def fan_mode_to_speed(ordered_fan_speeds: list[str], fan_mode: Any) -> int | None:
+ """Return the HomeKit rotation speed percentage for a climate fan mode.
+
+ Returns ``None`` when the mode is not one of the ordered predefined speeds.
+ """
+ if (
+ not isinstance(fan_mode, str)
+ or (fan_mode_lower := fan_mode.lower()) not in ordered_fan_speeds
+ ):
+ return None
+ return ordered_list_item_to_percentage(ordered_fan_speeds, fan_mode_lower)
+
+
+def is_swing_on(swing_mode: Any) -> bool:
+ """Return whether a climate swing mode maps to HomeKit swing on."""
+ return isinstance(swing_mode, str) and swing_mode.lower() in PRE_DEFINED_SWING_MODES
+
+
+def get_temperature_range_from_state(
+ state: State, unit: str, default_min: float, default_max: float
+) -> tuple[float, float]:
+ """Return the HomeKit min and max temperature range for a climate state.
+
+ Attribute values are in the entity's unit and converted to Celsius; the
+ defaults are already Celsius and used as-is. The minimum is clamped to zero
+ because the Home app crashes on negative bounds.
+ """
+ if (min_temp := state.attributes.get(ATTR_MIN_TEMP)) is not None:
+ min_temp = temperature_to_homekit(min_temp, unit)
+ else:
+ min_temp = default_min
+
+ if (max_temp := state.attributes.get(ATTR_MAX_TEMP)) is not None:
+ max_temp = temperature_to_homekit(max_temp, unit)
+ else:
+ max_temp = default_max
+
+ # Handle a reversed temperature range
+ min_temp, max_temp = get_min_max(min_temp, max_temp)
+
+ # Round inward to the characteristic's 0.1 step so the slider cannot
+ # produce a write the entity's own limit validation rejects; a range
+ # too narrow to hold a step keeps the exact limits rather than
+ # expanding beyond them.
+ rounded_min = math.ceil(min_temp * 10) / 10
+ rounded_max = math.floor(max_temp * 10) / 10
+ if rounded_min <= rounded_max:
+ min_temp, max_temp = rounded_min, rounded_max
+
+ min_temp = max(min_temp, 0)
+ max_temp = max(max_temp, min_temp)
+
+ return min_temp, max_temp
+
+
+def temperature_attribute_to_homekit(state: State, key: str, unit: str) -> float | None:
+ """Return a numeric temperature attribute converted to the HomeKit unit."""
+ value = state.attributes.get(key)
+ if isinstance(value, (int, float)):
+ return temperature_to_homekit(value, unit)
+ return None
+
+
+def resolve_target_temp_range(
+ current_high: float,
+ current_low: float,
+ new_high: float | None,
+ new_low: float | None,
+ min_temp: float,
+ max_temp: float,
+) -> tuple[float, float]:
+ """Return an ordered (high, low) target range within the temperature bounds.
+
+ The unchanged side keeps its current value and a deadband is enforced so
+ the range is never inverted.
+ """
+ high = current_high
+ low = current_low
+ deadband_enforced = False
+ if new_high is not None:
+ high = new_high
+ if high < low:
+ low = high - HEAT_COOL_DEADBAND
+ deadband_enforced = True
+ if new_low is not None:
+ low = new_low
+ if low > high:
+ high = low + HEAT_COOL_DEADBAND
+ deadband_enforced = True
+ high = min(high, max_temp)
+ low = max(low, min_temp)
+ # Clamping a deadband-adjusted setpoint to a bound can erase the gap it just
+ # enforced; restore it by moving the setpoint that is not pinned to the bound.
+ if deadband_enforced and high - low < HEAT_COOL_DEADBAND:
+ if high >= max_temp:
+ low = max(min_temp, high - HEAT_COOL_DEADBAND)
+ else:
+ high = min(max_temp, low + HEAT_COOL_DEADBAND)
+ return high, low
diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py
index 0d3294a1365e..fe50616a732b 100644
--- a/homeassistant/components/homekit/config_flow.py
+++ b/homeassistant/components/homekit/config_flow.py
@@ -12,6 +12,7 @@ import voluptuous as vol
from homeassistant.components import device_automation
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
+from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
@@ -31,6 +32,7 @@ from homeassistant.const import (
CONF_ENTITY_ID,
CONF_NAME,
CONF_PORT,
+ CONF_TYPE,
)
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.helpers import (
@@ -55,14 +57,24 @@ from .const import (
HOMEKIT_MODE_BRIDGE,
HOMEKIT_MODES,
SHORT_BRIDGE_NAME,
+ TYPE_HEATER_COOLER,
+ TYPE_THERMOSTAT,
VIDEO_CODEC_COPY,
)
+from .models import HomeKitEntryData
from .util import async_find_next_available_port, state_needs_accessory_mode
CONF_CAMERA_AUDIO = "camera_audio"
CONF_CAMERA_COPY = "camera_copy"
CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode"
+CLIMATE_TYPE_AUTOMATIC = "automatic"
+# Display names for the accessory classes a climate entity can use
+CLIMATE_ACCESSORY_NAMES = {
+ "Thermostat": "Thermostat",
+ "HeaterCooler": "Heater Cooler",
+}
+
MODE_INCLUDE = "include"
MODE_EXCLUDE = "exclude"
@@ -179,14 +191,29 @@ def _async_build_entities_filter(
)
-def _async_cameras_from_entities(entities: list[str]) -> list[str]:
+def _async_entities_in_domain(entities: list[str], domain: str) -> list[str]:
return [
- entity_id
- for entity_id in entities
- if entity_id.startswith(CAMERA_ENTITY_PREFIX)
+ entity_id for entity_id in entities if split_entity_id(entity_id)[0] == domain
]
+@callback
+def _async_included_domain_entities(
+ hass: HomeAssistant,
+ entity_filter: EntityFilterDict,
+ entities: list[str],
+ domain: str,
+) -> list[str]:
+ """Return a domain's included entities, expanding a whole domain include.
+
+ The whole domain is included when none of its entities are selected
+ explicitly.
+ """
+ if domain in entity_filter[CONF_INCLUDE_DOMAINS]:
+ return _async_get_matching_entities(hass, [domain])
+ return _async_entities_in_domain(entities, domain)
+
+
async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]:
"""Create a mapping of types of devices/entities HomeKit can support."""
integrations = await async_get_integrations(hass, SUPPORTED_DOMAINS)
@@ -374,6 +401,97 @@ class OptionsFlowHandler(OptionsFlow):
"""Initialize options flow."""
self.hk_options: dict[str, Any] = {}
self.included_cameras: list[str] = []
+ self.included_climates: list[str] = []
+ # Maps the displayed climate field label back to its entity id.
+ self._climate_choices: dict[str, str] = {}
+
+ async def async_step_climate(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Choose the accessory type for climate entities."""
+ if not self.included_climates:
+ return await self.async_step_bridged_device_triggers()
+
+ hk_options = self.hk_options
+ all_entity_config: dict[str, dict[str, Any]]
+
+ if user_input is not None:
+ all_entity_config = hk_options[CONF_ENTITY_CONFIG]
+ for label, entity_id in self._climate_choices.items():
+ entity_config = all_entity_config.setdefault(entity_id, {})
+
+ if (choice := user_input[label]) == CLIMATE_TYPE_AUTOMATIC:
+ entity_config.pop(CONF_TYPE, None)
+ else:
+ entity_config[CONF_TYPE] = choice
+
+ if not entity_config:
+ all_entity_config.pop(entity_id)
+
+ if not all_entity_config:
+ del hk_options[CONF_ENTITY_CONFIG]
+
+ return await self.async_step_bridged_device_triggers()
+
+ # Field labels come from the schema keys, so key the form by the
+ # friendly name and map back to the entity id on submit. The
+ # accessory a bridged entity currently uses is shown so Automatic
+ # is not a mystery.
+ current_accessories = self._async_current_climate_accessories()
+ self._climate_choices = {}
+ for entity_id in self.included_climates:
+ state = self.hass.states.get(entity_id)
+ label = f"{state.name} ({entity_id})" if state else entity_id
+ if current := current_accessories.get(entity_id):
+ label = f"{label} [{current}]"
+ self._climate_choices[label] = entity_id
+
+ all_entity_config = hk_options.setdefault(CONF_ENTITY_CONFIG, {})
+ type_selector = selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=[
+ CLIMATE_TYPE_AUTOMATIC,
+ TYPE_THERMOSTAT,
+ TYPE_HEATER_COOLER,
+ ],
+ translation_key="climate_accessory_type",
+ )
+ )
+ data_schema = vol.Schema(
+ {
+ vol.Required(
+ label,
+ default=all_entity_config.get(entity_id, {}).get(
+ CONF_TYPE, CLIMATE_TYPE_AUTOMATIC
+ ),
+ ): type_selector
+ for label, entity_id in self._climate_choices.items()
+ }
+ )
+ return self.async_show_form(step_id="climate", data_schema=data_schema)
+
+ @callback
+ def _async_current_climate_accessories(self) -> dict[str, str]:
+ """Map bridged climate entities to their current accessory name."""
+ entry_data: HomeKitEntryData | None = getattr(
+ self.config_entry, "runtime_data", None
+ )
+ if entry_data is None:
+ return {}
+ homekit = entry_data.homekit
+ accessories: Iterable[Any]
+ if homekit.bridge is not None:
+ accessories = homekit.bridge.accessories.values()
+ elif homekit.driver is not None and homekit.driver.accessory is not None:
+ accessories = [homekit.driver.accessory]
+ else:
+ return {}
+ return {
+ entity_id: CLIMATE_ACCESSORY_NAMES[accessory_name]
+ for accessory in accessories
+ if (entity_id := getattr(accessory, "entity_id", None)) is not None
+ and (accessory_name := type(accessory).__name__) in CLIMATE_ACCESSORY_NAMES
+ }
async def async_step_yaml(
self, user_input: dict[str, Any] | None = None
@@ -426,6 +544,9 @@ class OptionsFlowHandler(OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Choose camera config."""
+ if not self.included_cameras:
+ return await self.async_step_climate()
+
hk_options = self.hk_options
all_entity_config: dict[str, dict[str, Any]]
@@ -447,7 +568,7 @@ class OptionsFlowHandler(OptionsFlow):
if not entity_config:
all_entity_config.pop(entity_id)
- return await self.async_step_bridged_device_triggers()
+ return await self.async_step_climate()
cameras_with_audio = []
cameras_with_copy = []
@@ -492,11 +613,10 @@ class OptionsFlowHandler(OptionsFlow):
if user_input is not None:
entities = cv.ensure_list(user_input[CONF_ENTITIES])
entity_filter = _async_build_entities_filter(domains, entities)
- self.included_cameras = _async_cameras_from_entities(entities)
+ self.included_cameras = _async_entities_in_domain(entities, CAMERA_DOMAIN)
+ self.included_climates = _async_entities_in_domain(entities, CLIMATE_DOMAIN)
hk_options[CONF_FILTER] = entity_filter
- if self.included_cameras:
- return await self.async_step_cameras()
- return await self.async_step_bridged_device_triggers()
+ return await self.async_step_cameras()
entity_filter = hk_options.get(CONF_FILTER, {})
entities = entity_filter.get(CONF_INCLUDE_ENTITIES, [])
@@ -536,13 +656,17 @@ class OptionsFlowHandler(OptionsFlow):
domains = hk_options[CONF_DOMAINS]
if user_input is not None:
entities = cv.ensure_list(user_input[CONF_ENTITIES])
- self.included_cameras = _async_cameras_from_entities(entities)
- hk_options[CONF_FILTER] = _async_build_entities_filter(domains, entities)
- if self.included_cameras:
- return await self.async_step_cameras()
- return await self.async_step_bridged_device_triggers()
+ entity_filter = _async_build_entities_filter(domains, entities)
+ self.included_cameras = _async_included_domain_entities(
+ self.hass, entity_filter, entities, CAMERA_DOMAIN
+ )
+ self.included_climates = _async_included_domain_entities(
+ self.hass, entity_filter, entities, CLIMATE_DOMAIN
+ )
+ hk_options[CONF_FILTER] = entity_filter
+ return await self.async_step_cameras()
- entity_filter: EntityFilterDict = hk_options.get(CONF_FILTER, {})
+ entity_filter = hk_options.get(CONF_FILTER, {})
entities = entity_filter.get(CONF_INCLUDE_ENTITIES, [])
all_supported_entities = _async_get_matching_entities(
self.hass, domains, include_entity_category=True, include_hidden=True
@@ -579,23 +703,23 @@ class OptionsFlowHandler(OptionsFlow):
domains = hk_options[CONF_DOMAINS]
if user_input is not None:
- self.included_cameras = []
entities = cv.ensure_list(user_input[CONF_ENTITIES])
- if CAMERA_DOMAIN in domains:
- camera_entities = _async_get_matching_entities(
- self.hass, [CAMERA_DOMAIN]
- )
- self.included_cameras = [
+
+ def _remaining_in_domain(domain: str) -> list[str]:
+ if domain not in domains:
+ return []
+ return [
entity_id
- for entity_id in camera_entities
+ for entity_id in _async_get_matching_entities(self.hass, [domain])
if entity_id not in entities
]
+
+ self.included_cameras = _remaining_in_domain(CAMERA_DOMAIN)
+ self.included_climates = _remaining_in_domain(CLIMATE_DOMAIN)
hk_options[CONF_FILTER] = _make_entity_filter(
include_domains=domains, exclude_entities=entities
)
- if self.included_cameras:
- return await self.async_step_cameras()
- return await self.async_step_bridged_device_triggers()
+ return await self.async_step_cameras()
entity_filter = self.hk_options.get(CONF_FILTER, {})
entities = entity_filter.get(CONF_INCLUDE_ENTITIES, [])
diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py
index 5d5a8efc0e2a..0f69c4350f27 100644
--- a/homeassistant/components/homekit/const.py
+++ b/homeassistant/components/homekit/const.py
@@ -128,6 +128,9 @@ TYPE_VALVE = "valve"
TYPE_FAN = "fan"
TYPE_AIR_PURIFIER = "air_purifier"
+TYPE_HEATER_COOLER = "heater_cooler"
+TYPE_THERMOSTAT = "thermostat"
+
# #### Categories ####
CATEGORY_RECEIVER = 34
@@ -145,6 +148,7 @@ SERV_DOORBELL = "Doorbell"
SERV_FANV2 = "Fanv2"
SERV_FILTER_MAINTENANCE = "FilterMaintenance"
SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener"
+SERV_HEATER_COOLER = "HeaterCooler"
SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier"
SERV_HUMIDITY_SENSOR = "HumiditySensor"
SERV_INPUT_SOURCE = "InputSource"
@@ -193,6 +197,7 @@ CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel"
CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState"
CHAR_CURRENT_DOOR_STATE = "CurrentDoorState"
CHAR_CURRENT_FAN_STATE = "CurrentFanState"
+CHAR_CURRENT_HEATER_COOLER_STATE = "CurrentHeaterCoolerState"
CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState"
CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER = "CurrentHumidifierDehumidifierState"
CHAR_CURRENT_POSITION = "CurrentPosition"
@@ -245,6 +250,7 @@ CHAR_STREAMING_STRATUS = "StreamingStatus"
CHAR_SWING_MODE = "SwingMode"
CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState"
CHAR_TARGET_DOOR_STATE = "TargetDoorState"
+CHAR_TARGET_HEATER_COOLER_STATE = "TargetHeaterCoolerState"
CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
CHAR_TARGET_POSITION = "TargetPosition"
CHAR_TARGET_FAN_STATE = "TargetFanState"
diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json
index 3bb8625cba5d..6c93b3f65356 100644
--- a/homeassistant/components/homekit/strings.json
+++ b/homeassistant/components/homekit/strings.json
@@ -5,7 +5,7 @@
},
"step": {
"pairing": {
- "description": "To complete pairing follow the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d.",
+ "description": "To complete pairing follow the instructions in “Notifications” under “HomeKit Pairing”.",
"title": "Pair HomeKit"
},
"user": {
@@ -40,18 +40,22 @@
"description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single-board computers.",
"title": "Camera configuration"
},
+ "climate": {
+ "description": "Choose which accessory each climate entity uses in HomeKit. Heater Cooler puts the mode, temperature, and the supported fan speed and swing controls on one air conditioner style tile; Thermostat is the classic temperature dial with a separate fan. Automatic keeps the accessory the entity already uses and picks the best fit when it is bridged for the first time. Changing the accessory type keeps the room and name, but HomeKit scenes or automations that used the old controls may need to be recreated.",
+ "title": "Climate accessory configuration"
+ },
"exclude": {
"data": {
"entities": "[%key:component::homekit::options::step::include::data::entities%]"
},
- "description": "All \u201c{domains}\u201d entities will be included except for the excluded entities and categorized entities.",
+ "description": "All “{domains}” entities will be included except for the excluded entities and categorized entities.",
"title": "Select the entities to be excluded"
},
"include": {
"data": {
"entities": "Entities"
},
- "description": "Select entities from each domain in \u201c{domains}\u201d. The include will cover the entire domain if you do not select any entities for a given domain.",
+ "description": "Select entities from each domain in “{domains}”. The include will cover the entire domain if you do not select any entities for a given domain.",
"title": "Select the entities to be included"
},
"init": {
@@ -60,7 +64,7 @@
"include_exclude_mode": "Inclusion mode",
"mode": "HomeKit mode"
},
- "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.",
+ "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the “Domains to include” will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.",
"title": "Select mode and domains."
},
"yaml": {
@@ -69,6 +73,15 @@
}
}
},
+ "selector": {
+ "climate_accessory_type": {
+ "options": {
+ "automatic": "Automatic",
+ "heater_cooler": "Heater Cooler",
+ "thermostat": "Thermostat"
+ }
+ }
+ },
"services": {
"reload": {
"description": "Reloads HomeKit and re-processes the YAML-configuration.",
diff --git a/homeassistant/components/homekit/type_heater_coolers.py b/homeassistant/components/homekit/type_heater_coolers.py
new file mode 100644
index 000000000000..64f726911985
--- /dev/null
+++ b/homeassistant/components/homekit/type_heater_coolers.py
@@ -0,0 +1,734 @@
+"""Class to hold all heater cooler accessories."""
+
+import asyncio
+from collections.abc import Callable, Coroutine
+import functools
+import logging
+from typing import Any, Concatenate, NamedTuple, override
+
+from pyhap.characteristic import Characteristic
+from pyhap.const import CATEGORY_AIR_CONDITIONER, CATEGORY_HEATER
+
+from homeassistant.components.climate import (
+ ATTR_CURRENT_HUMIDITY,
+ ATTR_CURRENT_TEMPERATURE,
+ ATTR_HVAC_ACTION,
+ ATTR_HVAC_MODE,
+ ATTR_HVAC_MODES,
+ ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW,
+ ATTR_TEMPERATURE,
+ DOMAIN as CLIMATE_DOMAIN,
+ FAN_AUTO,
+ FAN_ON,
+ SERVICE_SET_FAN_MODE,
+ SERVICE_SET_HVAC_MODE,
+ SERVICE_SET_SWING_MODE,
+ SERVICE_SET_TEMPERATURE,
+ ClimateEntityFeature,
+ HVACAction,
+ HVACMode,
+)
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES
+from homeassistant.core import State, callback
+from homeassistant.util.enum import try_parse_enum
+
+from .accessories import TYPES
+from .climate_base import CLIMATE_INACTIVE_STATES, HomeKitClimateAccessory
+from .climate_util import temperature_attribute_to_homekit
+from .const import (
+ CHAR_ACTIVE,
+ CHAR_COOLING_THRESHOLD_TEMPERATURE,
+ CHAR_CURRENT_FAN_STATE,
+ CHAR_CURRENT_HEATER_COOLER_STATE,
+ CHAR_CURRENT_HUMIDITY,
+ CHAR_CURRENT_TEMPERATURE,
+ CHAR_HEATING_THRESHOLD_TEMPERATURE,
+ CHAR_NAME,
+ CHAR_ROTATION_SPEED,
+ CHAR_SWING_MODE,
+ CHAR_TARGET_FAN_STATE,
+ CHAR_TARGET_HEATER_COOLER_STATE,
+ PROP_MAX_VALUE,
+ PROP_MIN_STEP,
+ PROP_MIN_VALUE,
+ SERV_HEATER_COOLER,
+ SERV_HUMIDITY_SENSOR,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+# HomeKit CurrentHeaterCoolerState values (per HomeKit spec)
+HC_INACTIVE, HC_IDLE, HC_HEATING, HC_COOLING = range(4)
+
+# HomeKit TargetHeaterCoolerState valid values: Auto=0, Heat=1, Cool=2
+HC_TARGET_AUTO, HC_TARGET_HEAT, HC_TARGET_COOL = range(3)
+
+# Off is intentionally not mapped: when the entity is off the target
+# characteristic keeps the last active mode so it stays in sync with
+# _last_known_mode, which is what turning Active back on restores.
+HC_HASS_TO_HOMEKIT_TARGET = {
+ HVACMode.HEAT: HC_TARGET_HEAT,
+ HVACMode.COOL: HC_TARGET_COOL,
+ HVACMode.HEAT_COOL: HC_TARGET_AUTO,
+ HVACMode.AUTO: HC_TARGET_AUTO,
+}
+
+# HomeKit's CurrentHeaterCoolerState has no drying or fan-only value. Those
+# actions map to Cooling rather than Idle so the tile still shows the unit is
+# doing something, which also matches the Thermostat's action mapping.
+HC_HASS_TO_HOMEKIT_ACTION = {
+ HVACAction.OFF: HC_INACTIVE,
+ HVACAction.IDLE: HC_IDLE,
+ HVACAction.HEATING: HC_HEATING,
+ HVACAction.PREHEATING: HC_HEATING,
+ HVACAction.COOLING: HC_COOLING,
+ HVACAction.DRYING: HC_COOLING,
+ HVACAction.FAN: HC_COOLING,
+ HVACAction.DEFROSTING: HC_HEATING,
+}
+
+# Hysteresis band in Celsius used when the entity omits hvac_action
+ACTION_HYSTERESIS = 0.25
+
+
+class ClimateServiceCall(NamedTuple):
+ """A queued climate write and the modes to apply once accepted."""
+
+ service: str
+ data: dict[str, Any]
+ # Remembered as the Active on restore target
+ commit_mode: HVACMode | None = None
+ # Bridges resolution until the entity reports a mode change
+ pending_mode: HVACMode | None = None
+
+
+def _locked_write[**_P](
+ func: Callable[Concatenate[HeaterCooler, _P], Coroutine[Any, Any, None]],
+) -> Callable[Concatenate[HeaterCooler, _P], Coroutine[Any, Any, None]]:
+ """Run the write coroutine under the accessory's write lock."""
+
+ @functools.wraps(func)
+ async def _wrapper(self: HeaterCooler, *args: _P.args, **kwargs: _P.kwargs) -> None:
+ async with self._write_lock:
+ await func(self, *args, **kwargs)
+
+ return _wrapper
+
+
+# Modes that drive both a heating and a cooling threshold
+RANGE_MODES = (HVACMode.HEAT_COOL, HVACMode.AUTO)
+
+
+@TYPES.register("HeaterCooler")
+class HeaterCooler(HomeKitClimateAccessory):
+ """Generate a HeaterCooler accessory for a climate entity."""
+
+ # Configured only when the entity accepts a target temperature.
+ char_cool: Characteristic
+ char_heat: Characteristic
+
+ # Configured only when the entity reports a current humidity.
+ char_current_humidity: Characteristic
+
+ def __init__(self, *args: Any) -> None:
+ """Initialize a HeaterCooler accessory object."""
+ super().__init__(*args)
+
+ state = self.hass.states.get(self.entity_id)
+ assert state
+ attributes = state.attributes
+ features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ # The thresholds double as the setpoints, so only expose them when the
+ # entity accepts a target temperature; a fan/dry-only entity otherwise
+ # gets sliders that dispatch set_temperature it cannot honor.
+ has_thresholds = bool(
+ features
+ & (
+ ClimateEntityFeature.TARGET_TEMPERATURE
+ | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
+ )
+ )
+
+ hvac_modes = attributes.get(ATTR_HVAC_MODES, [])
+ current_mode = try_parse_enum(HVACMode, state.state)
+
+ self._supports_off = HVACMode.OFF in hvac_modes
+ supports_auto = HVACMode.AUTO in hvac_modes or current_mode == HVACMode.AUTO
+ supports_heat_cool = (
+ HVACMode.HEAT_COOL in hvac_modes or current_mode == HVACMode.HEAT_COOL
+ )
+
+ can_cool = HVACMode.COOL in hvac_modes or supports_auto or supports_heat_cool
+ can_heat = HVACMode.HEAT in hvac_modes or supports_auto or supports_heat_cool
+
+ # Standalone pairings advertise the category in the QR code and mDNS
+ # metadata, so pick the one matching the device instead of Thermostat;
+ # only a heat only device is a heater, everything else including the
+ # fan or dry only case is an air conditioner.
+ self.category = (
+ CATEGORY_HEATER if can_heat and not can_cool else CATEGORY_AIR_CONDITIONER
+ )
+
+ # Per the HomeKit spec a heater must include the heating threshold and a
+ # cooler the cooling one, so a one sided device gets a single slider. A
+ # device with neither side (e.g. dry only with a setpoint) keeps both,
+ # and a range only device always needs both sides in a write, so the
+ # setpoints stay controllable.
+ if (not can_cool and not can_heat) or not (
+ features & ClimateEntityFeature.TARGET_TEMPERATURE
+ ):
+ can_cool = can_heat = True
+ self._has_cool_threshold = has_thresholds and can_cool
+ self._has_heat_threshold = has_thresholds and can_heat
+
+ # Only expose the targets the entity actually supports so HomeKit does
+ # not offer a mode the climate service would reject. Auto is backed by
+ # a range mode, preferring HEAT_COOL whose thresholds stay adjustable
+ # over AUTO, which may follow a schedule, like the thermostat does.
+ self._hk_to_ha_target: dict[int, HVACMode] = {}
+ if HVACMode.HEAT in hvac_modes:
+ self._hk_to_ha_target[HC_TARGET_HEAT] = HVACMode.HEAT
+ if HVACMode.COOL in hvac_modes:
+ self._hk_to_ha_target[HC_TARGET_COOL] = HVACMode.COOL
+ if supports_heat_cool:
+ self._hk_to_ha_target[HC_TARGET_AUTO] = HVACMode.HEAT_COOL
+ elif supports_auto:
+ self._hk_to_ha_target[HC_TARGET_AUTO] = HVACMode.AUTO
+ if not self._hk_to_ha_target:
+ # Entities exposing neither heat, cool, nor a range mode (e.g.
+ # fan-only) still need a valid target; map Auto to the first mode the
+ # entity actually supports so the control does something. A degenerate
+ # off-only entity has no active mode, so fall back to off rather than
+ # an unsupported Auto.
+ fallback_mode = next(
+ (mode for mode in hvac_modes if mode != HVACMode.OFF), HVACMode.OFF
+ )
+ self._hk_to_ha_target[HC_TARGET_AUTO] = fallback_mode
+
+ chars = [
+ CHAR_ACTIVE,
+ CHAR_CURRENT_HEATER_COOLER_STATE,
+ CHAR_TARGET_HEATER_COOLER_STATE,
+ CHAR_CURRENT_TEMPERATURE,
+ ]
+ if self._has_cool_threshold:
+ chars.append(CHAR_COOLING_THRESHOLD_TEMPERATURE)
+ if self._has_heat_threshold:
+ chars.append(CHAR_HEATING_THRESHOLD_TEMPERATURE)
+
+ # The HeaterCooler service has no auto fan control, so when the entity
+ # exposes an auto fan mode (and a manual mode to switch back to) the fan
+ # is exposed through a full linked fan service instead; the rotation
+ # speed then lives there, since per the HomeKit spec it only belongs on
+ # the HeaterCooler when the fan cannot be independently controlled.
+ if FAN_AUTO in self.fan_modes and (
+ FAN_ON in self.fan_modes or self.ordered_fan_speeds
+ ):
+ self.fan_chars.append(CHAR_TARGET_FAN_STATE)
+ if self.ordered_fan_speeds:
+ self.fan_chars.append(CHAR_ROTATION_SPEED)
+ if attributes.get(ATTR_HVAC_ACTION) is not None:
+ self.fan_chars.append(CHAR_CURRENT_FAN_STATE)
+
+ # Fan/swing modes are detected in the base class; only advertise the
+ # characteristics when the entity exposes predefined modes.
+ if self.ordered_fan_speeds and not self.fan_chars:
+ chars.append(CHAR_ROTATION_SPEED)
+ if self.swing_on_mode is not None:
+ chars.append(CHAR_SWING_MODE)
+
+ serv = self.add_preload_service(SERV_HEATER_COOLER, chars)
+
+ self.char_active = serv.configure_char(CHAR_ACTIVE, value=0)
+ self.char_current_state = serv.configure_char(
+ CHAR_CURRENT_HEATER_COOLER_STATE, value=HC_INACTIVE
+ )
+ # Also the reverse lookup for modes only selectable through the
+ # Auto fallback, so every selectable mode is representable.
+ self._ha_to_hk_target = {
+ ha_mode: hk_state for hk_state, ha_mode in self._hk_to_ha_target.items()
+ }
+ if HC_TARGET_AUTO in self._hk_to_ha_target:
+ default_target = HC_TARGET_AUTO
+ else:
+ default_target = next(iter(self._hk_to_ha_target))
+ self.char_target_state = self._configure_target_mode_char(
+ serv,
+ CHAR_TARGET_HEATER_COOLER_STATE,
+ default_target,
+ self._ha_to_hk_target,
+ )
+ self._configure_current_temperature_char(serv)
+
+ if self._has_cool_threshold or self._has_heat_threshold:
+ min_temp_hk, max_temp_hk = self.get_temperature_range(state)
+ temp_properties = {
+ PROP_MIN_VALUE: min_temp_hk,
+ PROP_MAX_VALUE: max_temp_hk,
+ # We do not set PROP_MIN_STEP here and instead use the HomeKit
+ # default of 0.1 in order to have enough precision to convert
+ # temperature units and avoid setting 73F resulting in 74F
+ }
+ # Placeholder value within the configured range; async_update_state
+ # overwrites it from the entity immediately.
+ default_temp = min(max(21.0, min_temp_hk), max_temp_hk)
+ if self._has_cool_threshold:
+ self.char_cool = serv.configure_char(
+ CHAR_COOLING_THRESHOLD_TEMPERATURE,
+ value=default_temp,
+ properties=temp_properties,
+ )
+ if self._has_heat_threshold:
+ self.char_heat = serv.configure_char(
+ CHAR_HEATING_THRESHOLD_TEMPERATURE,
+ value=default_temp,
+ properties=temp_properties,
+ )
+
+ if self.ordered_fan_speeds and not self.fan_chars:
+ self.char_speed = serv.configure_char(
+ CHAR_ROTATION_SPEED,
+ value=100,
+ properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)},
+ )
+ if self.swing_on_mode is not None:
+ self.char_swing = serv.configure_char(CHAR_SWING_MODE, value=0)
+
+ if self.fan_chars:
+ self._configure_fan_service(serv)
+
+ # The Heater Cooler service has no humidity characteristic, so surface a
+ # reported current humidity through a linked humidity sensor. Like the
+ # Thermostat, this is decided once at setup and not a reload attribute:
+ # current humidity changes on every update, so reloading on it would
+ # thrash the accessory.
+ self._has_humidity = ATTR_CURRENT_HUMIDITY in attributes
+ if self._has_humidity:
+ humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME)
+ serv.add_linked_service(humidity_serv)
+ humidity_serv.configure_char(
+ CHAR_NAME, value=f"{self.display_name} Humidity"
+ )
+ self.char_current_humidity = humidity_serv.configure_char(
+ CHAR_CURRENT_HUMIDITY, value=50
+ )
+
+ # Every service exists now, so they all get an explicit primary
+ # flag; without one the Home app can pick its own tile service.
+ self.set_primary_service(serv)
+
+ # Fall back to the displayed target mode so turning Active on for a device
+ # that was off at startup activates the mode HomeKit is showing rather than
+ # an arbitrary one.
+ # Modes without a HomeKit target representation, like dry or fan
+ # only, are not remembered so turning Active on brings back the
+ # mode the tile is showing instead of one it cannot display.
+ self._last_known_mode: HVACMode
+ if current_mode and self._hk_target_mode(current_mode) is not None:
+ self._last_known_mode = current_mode
+ else:
+ self._last_known_mode = self._hk_to_ha_target[default_target]
+
+ self._write_lock = asyncio.Lock()
+ # A mode the entity accepted but does not report yet; push
+ # integrations can return from the service before their state
+ # callback arrives.
+ self._pending_mode: HVACMode | None = None
+ self._last_reported_mode = current_mode
+
+ self.async_update_state(state)
+
+ # A single service-level callback batches every characteristic write.
+ serv.setter_callback = self._set_chars
+
+ def _set_chars(self, char_values: dict[str, Any]) -> None:
+ """Handle writes to multiple HeaterCooler characteristics at once."""
+ _LOGGER.debug("HeaterCooler _set_chars: %s", char_values)
+ # Batches are resolved and applied under the accessory lock so a
+ # batch sees the outcome of the one before it and cannot overtake
+ # or interleave with it.
+ self.hass.async_create_task(
+ self._async_apply_batch(char_values), eager_start=True
+ )
+
+ @_locked_write
+ async def _async_apply_batch(self, char_values: dict[str, Any]) -> None:
+ """Resolve one characteristic batch and apply its writes in order.
+
+ A failed write aborts the rest of the batch, since the tile was
+ already re-synced and later writes would target a mode the entity
+ refused to enter.
+ """
+ service_calls: list[ClimateServiceCall] = []
+ current_state = self.hass.states.get(self.entity_id)
+ active = char_values.get(CHAR_ACTIVE)
+
+ # A mode written in the batch wins over the entity state, which
+ # still holds the pre-change mode.
+ requested_mode: HVACMode | None = None
+ if (
+ target_mode := char_values.get(CHAR_TARGET_HEATER_COOLER_STATE)
+ ) is not None:
+ requested_mode = self._hk_to_ha_target.get(target_mode)
+ elif active == 1:
+ # Turning on activates the last known mode, so setpoints in
+ # the same batch resolve against it instead of the off state.
+ requested_mode = self._last_known_mode
+
+ # Active/mode changes are handled first as they gate the others.
+ if self._handle_active_mode_changes(
+ active, target_mode, service_calls, current_state, requested_mode
+ ):
+ # A just accepted mode stays effective for the setpoints until
+ # the entity reports it, so a following batch does not resolve
+ # against the pre-switch state.
+ self._handle_temperature_changes(
+ char_values,
+ service_calls,
+ current_state,
+ requested_mode or self._pending_mode,
+ )
+ # Fan and swing are queued last so they follow the mode switch.
+ self._queue_fan_swing_changes(char_values, service_calls)
+
+ for call in service_calls:
+ reported_mode = self._last_reported_mode
+ known_mode = self._last_known_mode
+ if not await self.async_call_service_and_wait(
+ CLIMATE_DOMAIN,
+ call.service,
+ {ATTR_ENTITY_ID: self.entity_id, **call.data},
+ ):
+ return
+ # A state callback during the blocking call is fresher than the
+ # queued values, so each one only applies when its counterpart
+ # was not updated while awaiting. The remembered mode mirrors
+ # the accepted target, so a rejected mode is not restored later.
+ if call.pending_mode and self._last_reported_mode == reported_mode:
+ self._pending_mode = call.pending_mode
+ if call.commit_mode and self._last_known_mode == known_mode:
+ self._last_known_mode = call.commit_mode
+
+ @override
+ def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None:
+ """Serialize the write behind any batch still being applied."""
+ self.hass.async_create_task(
+ self._async_apply_locked_write(service, params), eager_start=True
+ )
+
+ @_locked_write
+ async def _async_apply_locked_write(
+ self, service: str, params: dict[str, Any]
+ ) -> None:
+ """Await one write under the accessory lock."""
+ await self.async_call_service_and_wait(
+ CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params}
+ )
+
+ def _queue_fan_swing_changes(
+ self,
+ char_values: dict[str, Any],
+ service_calls: list[ClimateServiceCall],
+ ) -> None:
+ """Queue fan speed and swing mode changes."""
+ if (
+ CHAR_ROTATION_SPEED in char_values
+ and (params := self._fan_speed_params(char_values[CHAR_ROTATION_SPEED]))
+ is not None
+ ):
+ service_calls.append(ClimateServiceCall(SERVICE_SET_FAN_MODE, params))
+ if (
+ CHAR_SWING_MODE in char_values
+ and (params := self._swing_mode_params(char_values[CHAR_SWING_MODE]))
+ is not None
+ ):
+ service_calls.append(ClimateServiceCall(SERVICE_SET_SWING_MODE, params))
+
+ def _handle_active_mode_changes(
+ self,
+ active: int | None,
+ target_mode: int | None,
+ service_calls: list[ClimateServiceCall],
+ current_state: State | None,
+ requested_mode: HVACMode | None,
+ ) -> bool:
+ """Handle active and mode changes.
+
+ Returns False when an off write terminates the batch; a rejected
+ off leaves the entity running, so the rest still applies.
+ """
+ if target_mode is not None and requested_mode is None:
+ # The write already changed the characteristic to a target the
+ # entity cannot enter, so put it back on the last mode.
+ if (restore := self._hk_target_mode(self._last_known_mode)) is not None:
+ self._reject_char_write(self.char_target_state, restore)
+
+ if active == 0:
+ # climate.turn_off raises for entities without an OFF mode; set the
+ # OFF mode directly and only when it is supported, like the thermostat.
+ if self._supports_off:
+ # A target bundled with off is already on the tile rather
+ # than sent, so it is committed with the accepted off write
+ # and restored by the next Active on.
+ service_calls.append(
+ ClimateServiceCall(
+ SERVICE_SET_HVAC_MODE,
+ {ATTR_HVAC_MODE: HVACMode.OFF},
+ commit_mode=requested_mode,
+ pending_mode=HVACMode.OFF,
+ )
+ )
+ return False
+ _LOGGER.debug(
+ "%s: Ignoring off request; entity has no off mode",
+ self.entity_id,
+ )
+ # The write already flipped the characteristic; flip it back so
+ # HomeKit keeps showing the unit as on, and let the rest of the
+ # batch apply since the entity keeps running.
+ self._reject_char_write(self.char_active, 1)
+ if requested_mode and (
+ target_mode is not None
+ or current_state is None
+ or self._pending_mode == HVACMode.OFF
+ or current_state.state in CLIMATE_INACTIVE_STATES
+ ):
+ # An explicit target always goes out; Active on sends the last
+ # known mode only when the entity is not already running, where
+ # a pending off write means it is about to stop running.
+ service_calls.append(
+ ClimateServiceCall(
+ SERVICE_SET_HVAC_MODE,
+ {ATTR_HVAC_MODE: requested_mode},
+ commit_mode=requested_mode,
+ pending_mode=requested_mode,
+ )
+ )
+ return True
+
+ def _handle_temperature_changes(
+ self,
+ char_values: dict[str, Any],
+ service_calls: list[ClimateServiceCall],
+ current_state: State | None,
+ requested_mode: HVACMode | None,
+ ) -> None:
+ """Handle temperature changes."""
+ cooling_temp = char_values.get(CHAR_COOLING_THRESHOLD_TEMPERATURE)
+ heating_temp = char_values.get(CHAR_HEATING_THRESHOLD_TEMPERATURE)
+
+ if cooling_temp is None and heating_temp is None:
+ return
+
+ # Entities that support both single and range targets publish the
+ # range keys even when they are unset, so the effective mode decides
+ # between a range and a single setpoint write; entities that only
+ # take a range always get one.
+ attributes = current_state.attributes if current_state else {}
+ effective_mode: HVACMode | str | None = requested_mode or (
+ current_state.state if current_state else None
+ )
+ use_range = (
+ self._has_cool_threshold
+ and self._has_heat_threshold
+ and (
+ ATTR_TARGET_TEMP_HIGH in attributes
+ or ATTR_TARGET_TEMP_LOW in attributes
+ )
+ and (effective_mode in RANGE_MODES or ATTR_TEMPERATURE not in attributes)
+ )
+
+ if use_range:
+ service_calls.append(
+ ClimateServiceCall(
+ SERVICE_SET_TEMPERATURE,
+ self._dual_setpoint_params(
+ self.char_cool, self.char_heat, cooling_temp, heating_temp
+ ),
+ )
+ )
+ else:
+ self._handle_single_temp_changes(
+ service_calls, cooling_temp, heating_temp, current_state, effective_mode
+ )
+
+ def _handle_single_temp_changes(
+ self,
+ service_calls: list[ClimateServiceCall],
+ cooling_temp: float | None,
+ heating_temp: float | None,
+ current_state: State | None,
+ effective_mode: HVACMode | str | None,
+ ) -> None:
+ """Handle temperature changes for single-temperature entities."""
+ if not current_state:
+ return
+
+ # For a single setpoint the effective mode decides which threshold is
+ # the setpoint; Cool uses the cooling side and Heat the heating side,
+ # so a write to the other side is ignored. Range and other modes fall
+ # back to whichever threshold moved, and Auto picks the one furthest
+ # from the current setpoint.
+ selected_temp = None
+ if effective_mode == HVACMode.COOL:
+ selected_temp = cooling_temp
+ elif effective_mode == HVACMode.HEAT:
+ selected_temp = heating_temp
+ elif (
+ effective_mode in RANGE_MODES
+ and cooling_temp is not None
+ and heating_temp is not None
+ ):
+ # Pick whichever threshold moved further from the entity's existing
+ # target setpoint. The thresholds are in HomeKit units, so convert
+ # the target setpoint before comparing.
+ target_temp = current_state.attributes.get(ATTR_TEMPERATURE)
+ if target_temp is None:
+ selected_temp = heating_temp
+ else:
+ target_temp_hk = self._temperature_to_homekit(target_temp)
+ if abs(cooling_temp - target_temp_hk) > abs(
+ heating_temp - target_temp_hk
+ ):
+ selected_temp = cooling_temp
+ else:
+ selected_temp = heating_temp
+ elif cooling_temp is not None:
+ selected_temp = cooling_temp
+ elif heating_temp is not None:
+ selected_temp = heating_temp
+
+ if selected_temp is not None:
+ ha_temp = self._temperature_to_states(selected_temp)
+ service_calls.append(
+ ClimateServiceCall(SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: ha_temp})
+ )
+
+ def _hk_target_mode(self, mode: HVACMode) -> int | None:
+ """Map HA hvac_mode to a HomeKit target heater-cooler state."""
+ # HomeKit's HeaterCooler target only has Auto/Heat/Cool, so modes like
+ # dry and fan_only have no representation; they are intentionally
+ # collapsed to the Auto target (see the fallback in __init__) and cannot
+ # be selected or reflected individually from the Home app.
+ hk_value = HC_HASS_TO_HOMEKIT_TARGET.get(mode)
+ if hk_value is not None and hk_value in self._hk_to_ha_target:
+ return hk_value
+ return self._ha_to_hk_target.get(mode)
+
+ @callback
+ @override
+ def async_update_state(self, new_state: State) -> None:
+ """Update state without rechecking the device features."""
+ attributes = new_state.attributes
+ current_mode = try_parse_enum(HVACMode, new_state.state)
+ if current_mode is not None:
+ if current_mode != self._last_reported_mode:
+ # The entity moved to a new mode, so its state is
+ # authoritative again; re-reports of the pre-switch mode,
+ # like attribute updates mid transition, keep the bridge.
+ self._pending_mode = None
+ self._last_reported_mode = current_mode
+ # While a write is pending, the accepted mode stays the displayed
+ # and restore target so a stale re-report cannot flip the tile back.
+ display_mode = self._pending_mode or current_mode
+ if display_mode and (tgt := self._hk_target_mode(display_mode)) is not None:
+ self._last_known_mode = display_mode
+ self.char_target_state.set_value(tgt)
+
+ if new_state.state in CLIMATE_INACTIVE_STATES:
+ # An off or unavailable entity is inactive, not idle.
+ self.char_active.set_value(0)
+ self.char_current_state.set_value(HC_INACTIVE)
+ else:
+ self.char_active.set_value(1)
+ action = attributes.get(ATTR_HVAC_ACTION) or self._derive_action(
+ new_state, current_mode
+ )
+ self.char_current_state.set_value(
+ HC_HASS_TO_HOMEKIT_ACTION.get(action, HC_INACTIVE)
+ )
+
+ self._update_current_temperature_char(new_state)
+ self._update_temperature_thresholds(new_state)
+ if self._has_humidity and isinstance(
+ (humidity := attributes.get(ATTR_CURRENT_HUMIDITY)), (int, float)
+ ):
+ self.char_current_humidity.set_value(humidity)
+ if self.fan_chars:
+ self._async_update_fan_service(new_state)
+ else:
+ # The base char updaters no-op when the entity exposes no fan/swing.
+ self._update_fan_speed_char(attributes)
+ self._update_swing_char(attributes)
+
+ def _update_temperature_thresholds(self, state: State) -> None:
+ """Update HomeKit temperature thresholds based on HA state."""
+ if not self._has_cool_threshold and not self._has_heat_threshold:
+ return
+ attributes = state.attributes
+ # Dual capable entities publish the range keys even in single
+ # setpoint modes, so only values decide what is displayed.
+ supports_dual_temp = (
+ attributes.get(ATTR_TARGET_TEMP_HIGH) is not None
+ or attributes.get(ATTR_TARGET_TEMP_LOW) is not None
+ )
+
+ if supports_dual_temp:
+ if self._has_cool_threshold:
+ self._update_temperature_char(
+ self.char_cool, state, ATTR_TARGET_TEMP_HIGH
+ )
+ if self._has_heat_threshold:
+ self._update_temperature_char(
+ self.char_heat, state, ATTR_TARGET_TEMP_LOW
+ )
+ elif (
+ target_temp := temperature_attribute_to_homekit(
+ state, ATTR_TEMPERATURE, self._unit
+ )
+ ) is not None:
+ if self._has_cool_threshold:
+ self.char_cool.set_value(target_temp)
+ if self._has_heat_threshold:
+ self.char_heat.set_value(target_temp)
+
+ def _derive_action(self, state: State, mode: HVACMode | None) -> HVACAction:
+ """Infer heating / cooling when integration omits hvac_action."""
+ attributes = state.attributes
+ cur = attributes.get(ATTR_CURRENT_TEMPERATURE)
+ if cur is None or mode is None:
+ return HVACAction.IDLE
+
+ # Resolve the cool-above and heat-below setpoints for the active mode.
+ # Range modes have independent thresholds; single-target modes only
+ # drive one side. Any other mode (e.g. dry, fan_only) stays idle.
+ if mode in RANGE_MODES:
+ cool_above = attributes.get(ATTR_TARGET_TEMP_HIGH)
+ heat_below = attributes.get(ATTR_TARGET_TEMP_LOW)
+ if cool_above is None and heat_below is None:
+ # Some integrations run auto from a single setpoint.
+ cool_above = heat_below = attributes.get(ATTR_TEMPERATURE)
+ elif mode == HVACMode.COOL:
+ cool_above = attributes.get(ATTR_TEMPERATURE)
+ heat_below = None
+ elif mode == HVACMode.HEAT:
+ cool_above = None
+ heat_below = attributes.get(ATTR_TEMPERATURE)
+ else:
+ return HVACAction.IDLE
+
+ # Compare in Celsius so the hysteresis band is unit independent.
+ cur_c = self._temperature_to_homekit(cur)
+ if (
+ cool_above is not None
+ and cur_c > self._temperature_to_homekit(cool_above) + ACTION_HYSTERESIS
+ ):
+ return HVACAction.COOLING
+ if (
+ heat_below is not None
+ and cur_c < self._temperature_to_homekit(heat_below) - ACTION_HYSTERESIS
+ ):
+ return HVACAction.HEATING
+ return HVACAction.IDLE
diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py
index a0d9830de4f0..a9b2d45d6e87 100644
--- a/homeassistant/components/homekit/type_switches.py
+++ b/homeassistant/components/homekit/type_switches.py
@@ -556,6 +556,6 @@ class SelectSwitch(HomeAccessory):
@override
def async_update_state(self, new_state: State) -> None:
"""Update switch state after state changed."""
- current_option = cleanup_name_for_homekit(new_state.state)
+ current_option = new_state.state
for option, char in self.select_chars.items():
char.set_value(option == current_option)
diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py
index 95d2da4d942d..72352dde87b0 100644
--- a/homeassistant/components/homekit/type_thermostats.py
+++ b/homeassistant/components/homekit/type_thermostats.py
@@ -8,8 +8,6 @@ from pyhap.const import CATEGORY_THERMOSTAT
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
- ATTR_FAN_MODE,
- ATTR_FAN_MODES,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
@@ -18,32 +16,16 @@ from homeassistant.components.climate import (
ATTR_MAX_TEMP,
ATTR_MIN_HUMIDITY,
ATTR_MIN_TEMP,
- ATTR_SWING_MODE,
- ATTR_SWING_MODES,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
DEFAULT_MAX_HUMIDITY,
- DEFAULT_MAX_TEMP,
DEFAULT_MIN_HUMIDITY,
- DEFAULT_MIN_TEMP,
DOMAIN as CLIMATE_DOMAIN,
FAN_AUTO,
- FAN_HIGH,
- FAN_LOW,
- FAN_MEDIUM,
- FAN_MIDDLE,
- FAN_OFF,
FAN_ON,
- SERVICE_SET_FAN_MODE,
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT,
- SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT,
- SWING_BOTH,
- SWING_HORIZONTAL,
- SWING_OFF,
- SWING_ON,
- SWING_VERTICAL,
ClimateEntityFeature,
HVACAction,
HVACMode,
@@ -70,14 +52,14 @@ from homeassistant.const import (
)
from homeassistant.core import State, callback
from homeassistant.util.enum import try_parse_enum
-from homeassistant.util.percentage import (
- ordered_list_item_to_percentage,
- percentage_to_ordered_list_item,
-)
from .accessories import TYPES, HomeAccessory
+from .climate_base import HomeKitClimateAccessory
+from .climate_util import (
+ get_temperature_range_from_state,
+ temperature_attribute_to_homekit,
+)
from .const import (
- CHAR_ACTIVE,
CHAR_COOLING_THRESHOLD_TEMPERATURE,
CHAR_CURRENT_FAN_STATE,
CHAR_CURRENT_HEATING_COOLING,
@@ -94,12 +76,10 @@ from .const import (
DEFAULT_MAX_TEMP_WATER_HEATER,
DEFAULT_MIN_TEMP_WATER_HEATER,
PROP_MAX_VALUE,
- PROP_MIN_STEP,
PROP_MIN_VALUE,
- SERV_FANV2,
SERV_THERMOSTAT,
)
-from .util import get_min_max, temperature_to_homekit, temperature_to_states
+from .util import get_min_max, temperature_to_states
_LOGGER = logging.getLogger(__name__)
@@ -132,11 +112,6 @@ HC_HEAT_COOL_PREFER_COOL = [
HC_HEAT_COOL_OFF,
]
-ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH]
-PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS)
-SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL]
-PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER)
-
HC_MIN_TEMP = 10
HC_MAX_TEMP = 38
@@ -163,23 +138,6 @@ HC_HASS_TO_HOMEKIT_ACTION = {
HVACAction.DEFROSTING: HC_HEAT_COOL_HEAT,
}
-FAN_STATE_INACTIVE = 0
-FAN_STATE_IDLE = 1
-FAN_STATE_ACTIVE = 2
-
-HC_HASS_TO_HOMEKIT_FAN_STATE = {
- HVACAction.OFF: FAN_STATE_INACTIVE,
- HVACAction.IDLE: FAN_STATE_IDLE,
- HVACAction.HEATING: FAN_STATE_ACTIVE,
- HVACAction.COOLING: FAN_STATE_ACTIVE,
- HVACAction.DRYING: FAN_STATE_ACTIVE,
- HVACAction.FAN: FAN_STATE_ACTIVE,
- HVACAction.PREHEATING: FAN_STATE_IDLE,
- HVACAction.DEFROSTING: FAN_STATE_IDLE,
-}
-
-HEAT_COOL_DEADBAND = 5
-
def _hk_hvac_mode_from_state(state: State) -> int | None:
"""Return the equivalent HomeKit HVAC mode for a given state."""
@@ -194,29 +152,20 @@ def _hk_hvac_mode_from_state(state: State) -> int | None:
@TYPES.register("Thermostat")
-class Thermostat(HomeAccessory):
+class Thermostat(HomeKitClimateAccessory):
"""Generate a Thermostat accessory for a climate."""
def __init__(self, *args: Any) -> None:
"""Initialize a Thermostat accessory object."""
- super().__init__(*args, category=CATEGORY_THERMOSTAT)
- self._unit = self.hass.config.units.temperature_unit
+ super().__init__(*args)
state = self.hass.states.get(self.entity_id)
assert state
hc_min_temp, hc_max_temp = self.get_temperature_range(state)
- self._reload_on_change_attrs.extend(
- (
- ATTR_MIN_HUMIDITY,
- ATTR_MAX_TEMP,
- ATTR_MIN_TEMP,
- ATTR_FAN_MODES,
- ATTR_HVAC_MODES,
- )
- )
+ # The common climate reload attributes are added by the base class.
+ self._reload_on_change_attrs.append(ATTR_MIN_HUMIDITY)
# Add additional characteristics if auto mode is supported
self.chars: list[str] = []
- self.fan_chars: list[str] = []
attributes = state.attributes
min_humidity, _ = get_min_max(
@@ -240,7 +189,6 @@ class Thermostat(HomeAccessory):
self.chars.append(CHAR_TARGET_HUMIDITY)
serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars)
- self.set_primary_service(serv_thermostat)
# Current mode characteristics
self.char_current_heat_cool = serv_thermostat.configure_char(
@@ -248,22 +196,14 @@ class Thermostat(HomeAccessory):
)
self._configure_hvac_modes(state)
- # Must set the value first as setting
- # valid_values happens before setting
- # the value and if 0 is not a valid
- # value this will throw
- self.char_target_heat_cool = serv_thermostat.configure_char(
- CHAR_TARGET_HEATING_COOLING, value=list(self.hc_homekit_to_hass)[0]
+ self.char_target_heat_cool = self._configure_target_mode_char(
+ serv_thermostat,
+ CHAR_TARGET_HEATING_COOLING,
+ list(self.hc_homekit_to_hass)[0],
+ self.hc_hass_to_homekit,
)
- self.char_target_heat_cool.override_properties(
- valid_values=self.hc_hass_to_homekit
- )
- self.char_target_heat_cool.allow_invalid_client_values = True
- # Current and target temperature characteristics
- self.char_current_temp = serv_thermostat.configure_char(
- CHAR_CURRENT_TEMPERATURE, value=21.0
- )
+ self._configure_current_temperature_char(serv_thermostat)
self.char_target_temp = serv_thermostat.configure_char(
CHAR_TARGET_TEMPERATURE,
@@ -318,123 +258,31 @@ class Thermostat(HomeAccessory):
CHAR_CURRENT_HUMIDITY, value=50
)
- fan_modes: dict[str, str] = {}
- self.ordered_fan_speeds: list[str] = []
+ # Fan/swing modes are detected in the base class.
+ if self.ordered_fan_speeds:
+ self.fan_chars.append(CHAR_ROTATION_SPEED)
- if features & ClimateEntityFeature.FAN_MODE:
- fan_modes = {
- fan_mode.lower(): fan_mode
- for fan_mode in attributes.get(ATTR_FAN_MODES) or []
- }
- if fan_modes and PRE_DEFINED_FAN_MODES.intersection(fan_modes):
- self.ordered_fan_speeds = [
- speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
- ]
- self.fan_chars.append(CHAR_ROTATION_SPEED)
-
- if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speeds):
+ if FAN_AUTO in self.fan_modes and (
+ FAN_ON in self.fan_modes or self.ordered_fan_speeds
+ ):
self.fan_chars.append(CHAR_TARGET_FAN_STATE)
- self.fan_modes = fan_modes
- if (
- features & ClimateEntityFeature.SWING_MODE
- and (swing_modes := attributes.get(ATTR_SWING_MODES))
- and PRE_DEFINED_SWING_MODES.intersection(swing_modes)
- ):
- self.swing_on_mode = next(
- iter(
- swing_mode
- for swing_mode in SWING_MODE_PREFERRED_ORDER
- if swing_mode in swing_modes
- )
- )
+ if self.swing_on_mode:
self.fan_chars.append(CHAR_SWING_MODE)
if self.fan_chars:
if attributes.get(ATTR_HVAC_ACTION) is not None:
self.fan_chars.append(CHAR_CURRENT_FAN_STATE)
- serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars)
- serv_thermostat.add_linked_service(serv_fan)
- self.char_active = serv_fan.configure_char(
- CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active
- )
- if CHAR_SWING_MODE in self.fan_chars:
- self.char_swing = serv_fan.configure_char(
- CHAR_SWING_MODE,
- value=0,
- setter_callback=self._set_fan_swing_mode,
- )
- self.char_swing.display_name = "Swing Mode"
- if CHAR_ROTATION_SPEED in self.fan_chars:
- self.char_speed = serv_fan.configure_char(
- CHAR_ROTATION_SPEED,
- value=100,
- properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)},
- setter_callback=self._set_fan_speed,
- )
- self.char_speed.display_name = "Fan Mode"
- if CHAR_CURRENT_FAN_STATE in self.fan_chars:
- self.char_current_fan_state = serv_fan.configure_char(
- CHAR_CURRENT_FAN_STATE,
- value=0,
- )
- self.char_current_fan_state.display_name = "Fan State"
- if CHAR_TARGET_FAN_STATE in self.fan_chars and FAN_AUTO in self.fan_modes:
- self.char_target_fan_state = serv_fan.configure_char(
- CHAR_TARGET_FAN_STATE,
- value=0,
- setter_callback=self._set_fan_auto,
- )
- self.char_target_fan_state.display_name = "Fan Auto"
+ self._configure_fan_service(serv_thermostat)
+
+ # Every service exists now, so they all get an explicit primary
+ # flag; without one the Home app can pick its own tile service.
+ self.set_primary_service(serv_thermostat)
self.async_update_state(state)
serv_thermostat.setter_callback = self._set_chars
- def _set_fan_swing_mode(self, swing_on: int) -> None:
- _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on)
- mode = self.swing_on_mode if swing_on else SWING_OFF
- params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode}
- self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, params)
-
- def _set_fan_speed(self, speed: int) -> None:
- _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed)
- speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, speed - 1)
- mode = self.fan_modes[speed_key]
- params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}
- self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params)
-
- def _get_on_mode(self) -> str:
- if self.ordered_fan_speeds:
- speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50)
- return self.fan_modes[speed_key]
- return self.fan_modes[FAN_ON]
-
- def _set_fan_active(self, active: int) -> None:
- _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active)
- if FAN_OFF not in self.fan_modes:
- _LOGGER.debug(
- "%s: Fan does not support off, resetting to on", self.entity_id
- )
- self.char_active.value = 1
- self.char_active.notify()
- return
- mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF]
- params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}
- self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params)
-
- def _set_fan_auto(self, auto: int) -> None:
- _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto)
- mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode()
- params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}
- self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params)
-
- def _temperature_to_homekit(self, temp: float) -> float:
- return temperature_to_homekit(temp, self._unit)
-
- def _temperature_to_states(self, temp: float) -> float:
- return temperature_to_states(temp, self._unit)
-
def _set_chars(self, char_values: dict[str, Any]) -> None:
_LOGGER.debug("Thermostat _set_chars: %s", char_values)
events = []
@@ -458,7 +306,9 @@ class Thermostat(HomeAccessory):
# siri will always send HC_HEAT_COOL_AUTO in this case
# and hope for the best.
hc_target_temp = char_values.get(CHAR_TARGET_TEMPERATURE)
- hc_current_temp = _get_current_temperature(state, self._unit)
+ hc_current_temp = temperature_attribute_to_homekit(
+ state, ATTR_CURRENT_TEMPERATURE, self._unit
+ )
hc_fallback_order = HC_HEAT_COOL_PREFER_HEAT
if (
hc_target_temp is not None
@@ -531,38 +381,20 @@ class Thermostat(HomeAccessory):
assert self.char_cooling_thresh_temp
assert self.char_heating_thresh_temp
service = SERVICE_SET_TEMPERATURE_THERMOSTAT
- high = self.char_cooling_thresh_temp.value
- low = self.char_heating_thresh_temp.value
- min_temp, max_temp = self.get_temperature_range(state)
- if CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values:
- events.append(
- f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to"
- f" {char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]}°C"
- )
- high = char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]
- # If the device doesn't support TARGET_TEMPATURE
- # this can happen
- if high < low:
- low = high - HEAT_COOL_DEADBAND
- if CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values:
- events.append(
- f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to"
- f" {char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]}°C"
- )
- low = char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]
- # If the device doesn't support TARGET_TEMPATURE
- # this can happen
- if low > high:
- high = low + HEAT_COOL_DEADBAND
-
- high = min(high, max_temp)
- low = max(low, min_temp)
-
+ new_high = char_values.get(CHAR_COOLING_THRESHOLD_TEMPERATURE)
+ new_low = char_values.get(CHAR_HEATING_THRESHOLD_TEMPERATURE)
+ if new_high is not None:
+ events.append(f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to {new_high}°C")
+ if new_low is not None:
+ events.append(f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to {new_low}°C")
+ # A device without TARGET_TEMPERATURE can send an inverted pair.
params.update(
- {
- ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high),
- ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low),
- }
+ self._dual_setpoint_params(
+ self.char_cooling_thresh_temp,
+ self.char_heating_thresh_temp,
+ new_high,
+ new_low,
+ )
)
if service:
@@ -604,15 +436,6 @@ class Thermostat(HomeAccessory):
}
self.hc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hass.items()}
- def get_temperature_range(self, state: State) -> tuple[float, float]:
- """Return min and max temperature range."""
- return _get_temperature_range_from_state(
- state,
- self._unit,
- DEFAULT_MIN_TEMP,
- DEFAULT_MAX_TEMP,
- )
-
def set_target_humidity(self, value: float) -> None:
"""Set target humidity to value if call came from HomeKit."""
_LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value)
@@ -648,10 +471,7 @@ class Thermostat(HomeAccessory):
HC_HASS_TO_HOMEKIT_ACTION.get(hvac_action, HC_HEAT_COOL_OFF)
)
- # Update current temperature
- current_temp = _get_current_temperature(new_state, self._unit)
- if current_temp is not None:
- self.char_current_temp.set_value(current_temp)
+ self._update_current_temperature_char(new_state)
# Update current humidity
if CHAR_CURRENT_HUMIDITY in self.chars:
@@ -667,22 +487,20 @@ class Thermostat(HomeAccessory):
if isinstance(target_humdity, (int, float)):
self.char_target_humidity.set_value(target_humdity)
- # Update cooling threshold temperature if characteristic exists
+ # Update threshold temperatures if the characteristics exist
if self.char_cooling_thresh_temp:
- cooling_thresh = attributes.get(ATTR_TARGET_TEMP_HIGH)
- if isinstance(cooling_thresh, (int, float)):
- cooling_thresh = self._temperature_to_homekit(cooling_thresh)
- self.char_cooling_thresh_temp.set_value(cooling_thresh)
-
- # Update heating threshold temperature if characteristic exists
+ self._update_temperature_char(
+ self.char_cooling_thresh_temp, new_state, ATTR_TARGET_TEMP_HIGH
+ )
if self.char_heating_thresh_temp:
- heating_thresh = attributes.get(ATTR_TARGET_TEMP_LOW)
- if isinstance(heating_thresh, (int, float)):
- heating_thresh = self._temperature_to_homekit(heating_thresh)
- self.char_heating_thresh_temp.set_value(heating_thresh)
+ self._update_temperature_char(
+ self.char_heating_thresh_temp, new_state, ATTR_TARGET_TEMP_LOW
+ )
# Update target temperature
- target_temp = _get_target_temperature(new_state, self._unit)
+ target_temp = temperature_attribute_to_homekit(
+ new_state, ATTR_TEMPERATURE, self._unit
+ )
if (
target_temp is None
and features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
@@ -707,42 +525,7 @@ class Thermostat(HomeAccessory):
self.char_display_units.set_value(unit)
if self.fan_chars:
- self._async_update_fan_state(new_state)
-
- @callback
- def _async_update_fan_state(self, new_state: State) -> None:
- """Update state without rechecking the device features."""
- attributes = new_state.attributes
-
- if CHAR_SWING_MODE in self.fan_chars and (
- swing_mode := attributes.get(ATTR_SWING_MODE)
- ):
- swing = 1 if swing_mode in PRE_DEFINED_SWING_MODES else 0
- self.char_swing.set_value(swing)
-
- fan_mode = attributes.get(ATTR_FAN_MODE)
- fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None
- if (
- CHAR_ROTATION_SPEED in self.fan_chars
- and fan_mode_lower in self.ordered_fan_speeds
- ):
- self.char_speed.set_value(
- ordered_list_item_to_percentage(self.ordered_fan_speeds, fan_mode_lower)
- )
-
- if CHAR_TARGET_FAN_STATE in self.fan_chars:
- self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0)
-
- if CHAR_CURRENT_FAN_STATE in self.fan_chars and (
- hvac_action := attributes.get(ATTR_HVAC_ACTION)
- ):
- self.char_current_fan_state.set_value(
- HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action]
- )
-
- self.char_active.set_value(
- int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF)
- )
+ self._async_update_fan_service(new_state)
@TYPES.register("WaterHeater")
@@ -811,7 +594,7 @@ class WaterHeater(HomeAccessory):
def get_temperature_range(self, state: State) -> tuple[float, float]:
"""Return min and max temperature range."""
- return _get_temperature_range_from_state(
+ return get_temperature_range_from_state(
state,
self._unit,
DEFAULT_MIN_TEMP_WATER_HEATER,
@@ -881,11 +664,15 @@ class WaterHeater(HomeAccessory):
def async_update_state(self, new_state: State) -> None:
"""Update water_heater state after state change."""
# Update current and target temperature
- target_temperature = _get_target_temperature(new_state, self._unit)
+ target_temperature = temperature_attribute_to_homekit(
+ new_state, ATTR_TEMPERATURE, self._unit
+ )
if target_temperature is not None:
self.char_target_temp.set_value(target_temperature)
- current_temperature = _get_current_temperature(new_state, self._unit)
+ current_temperature = temperature_attribute_to_homekit(
+ new_state, ATTR_CURRENT_TEMPERATURE, self._unit
+ )
if current_temperature is not None:
self.char_current_temp.set_value(current_temperature)
@@ -902,45 +689,3 @@ class WaterHeater(HomeAccessory):
else:
self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT)
self.char_current_heat_cool.set_value(HC_HEAT_COOL_HEAT)
-
-
-def _get_temperature_range_from_state(
- state: State, unit: str, default_min: float, default_max: float
-) -> tuple[float, float]:
- """Calculate the temperature range from a state."""
- if min_temp := state.attributes.get(ATTR_MIN_TEMP):
- min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2
- else:
- min_temp = default_min
-
- if max_temp := state.attributes.get(ATTR_MAX_TEMP):
- max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2
- else:
- max_temp = default_max
-
- # Handle reversed temperature range
- min_temp, max_temp = get_min_max(min_temp, max_temp)
-
- # Homekit only supports 10-38, overwriting
- # the max to appears to work, but less than 0 causes
- # a crash on the home app
- min_temp = max(min_temp, 0)
- max_temp = max(max_temp, min_temp)
-
- return min_temp, max_temp
-
-
-def _get_target_temperature(state: State, unit: str) -> float | None:
- """Calculate the target temperature from a state."""
- target_temp = state.attributes.get(ATTR_TEMPERATURE)
- if isinstance(target_temp, (int, float)):
- return temperature_to_homekit(target_temp, unit)
- return None
-
-
-def _get_current_temperature(state: State, unit: str) -> float | None:
- """Calculate the current temperature from a state."""
- current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE)
- if isinstance(current_temp, (int, float)):
- return temperature_to_homekit(current_temp, unit)
- return None
diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py
index 97269181d58a..bc1b65a365c9 100644
--- a/homeassistant/components/homekit/util.py
+++ b/homeassistant/components/homekit/util.py
@@ -106,10 +106,12 @@ from .const import (
TYPE_AIR_PURIFIER,
TYPE_FAN,
TYPE_FAUCET,
+ TYPE_HEATER_COOLER,
TYPE_OUTLET,
TYPE_SHOWER,
TYPE_SPRINKLER,
TYPE_SWITCH,
+ TYPE_THERMOSTAT,
TYPE_VALVE,
VIDEO_CODEC_COPY,
VIDEO_CODEC_H264_OMX,
@@ -225,6 +227,21 @@ COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
}
)
+# No default so an unset type keeps the automatic Thermostat/HeaterCooler routing.
+CLIMATE_SCHEMA = BASIC_INFO_SCHEMA.extend(
+ {
+ vol.Optional(CONF_TYPE): vol.All(
+ cv.string,
+ vol.In(
+ (
+ TYPE_HEATER_COOLER,
+ TYPE_THERMOSTAT,
+ )
+ ),
+ ),
+ }
+)
+
CODE_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)}
)
@@ -360,6 +377,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "humidifier":
config = HUMIDIFIER_SCHEMA(config)
+ elif domain == "climate":
+ config = CLIMATE_SCHEMA(config)
+
elif domain == "cover":
config = COVER_SCHEMA(config)
diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py
index e18631c7049b..46934718b7ef 100644
--- a/homeassistant/components/homematicip_cloud/__init__.py
+++ b/homeassistant/components/homematicip_cloud/__init__.py
@@ -48,7 +48,7 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the HomematicIP Cloud component."""
+ """Set up the HomematicIP Cloud integration."""
accesspoints = config.get(DOMAIN, [])
for conf in accesspoints:
diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py
index ae948ce5b4c9..3a017690ee26 100644
--- a/homeassistant/components/homematicip_cloud/button.py
+++ b/homeassistant/components/homematicip_cloud/button.py
@@ -2,6 +2,7 @@
from typing import override
+from homematicip.base.functionalChannels import AccessAuthorizationChannel
from homematicip.device import WallMountedGarageDoorController
from homeassistant.components.button import ButtonEntity
@@ -12,11 +13,17 @@ from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry, HomematicipHAP
-def _is_full_flush_lock_controller(device: object) -> bool:
- """Return whether the device is an HmIP-FLC."""
- return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr(
- device, "send_start_impulse_async"
- )
+def _door_opener_authorization_channel(
+ device: object,
+) -> AccessAuthorizationChannel | None:
+ """Return the AccessAuthorizationChannel routed to the door opener."""
+ for channel in getattr(device, "functionalChannels", []):
+ if (
+ isinstance(channel, AccessAuthorizationChannel)
+ and getattr(channel, "channelRole", None) == "DOOR_OPENER_ACTUATOR"
+ ):
+ return channel
+ return None
async def async_setup_entry(
@@ -33,9 +40,10 @@ async def async_setup_entry(
if isinstance(device, WallMountedGarageDoorController)
]
entities.extend(
- HomematicipFullFlushLockControllerButton(hap, device)
+ HomematicipFullFlushLockControllerButton(hap, device, auth_channel)
for device in hap.home.devices
- if _is_full_flush_lock_controller(device)
+ if getattr(device, "modelType", None) == "HmIP-FLC"
+ and (auth_channel := _door_opener_authorization_channel(device)) is not None
)
async_add_entities(entities)
@@ -57,14 +65,24 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti
class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity):
"""Representation of the HomematicIP full flush lock controller opener."""
- def __init__(self, hap: HomematicipHAP, device) -> None:
+ def __init__(
+ self,
+ hap: HomematicipHAP,
+ device,
+ auth_channel: AccessAuthorizationChannel,
+ ) -> None:
"""Initialize the full flush lock controller opener button."""
super().__init__(
hap, device, post="Door opener", feature_id="lock_opener_button"
)
self._attr_icon = "mdi:door-open"
+ self._auth_channel = auth_channel
@override
async def async_press(self) -> None:
- """Handle the button press."""
- await self._device.send_start_impulse_async()
+ """Pull the latch via the access-authorization channel.
+
+ This is the only path non-admin clients may use; the door-switch
+ channel rejects them with CLIENT_ACCESS_DENIED.
+ """
+ await self._auth_channel.async_pull_latch()
diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py
index aae5a6227e37..1fe8b126ea62 100644
--- a/homeassistant/components/homematicip_cloud/config_flow.py
+++ b/homeassistant/components/homematicip_cloud/config_flow.py
@@ -1,4 +1,4 @@
-"""Config flow to configure the HomematicIP Cloud component."""
+"""Config flow to configure the HomematicIP Cloud integration."""
from collections.abc import Mapping
from typing import Any, override
@@ -12,7 +12,7 @@ from .hap import HomematicipAuth
class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
- """Config flow for the HomematicIP Cloud component."""
+ """Config flow for the HomematicIP Cloud integration."""
VERSION = 2
diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py
index 07e4fbadeb7a..60caa168311e 100644
--- a/homeassistant/components/homematicip_cloud/const.py
+++ b/homeassistant/components/homematicip_cloud/const.py
@@ -1,4 +1,4 @@
-"""Constants for the HomematicIP Cloud component."""
+"""Constants for the HomematicIP Cloud integration."""
import logging
diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py
index 2e4889a53d8c..5e2947b902fe 100644
--- a/homeassistant/components/homematicip_cloud/entity.py
+++ b/homeassistant/components/homematicip_cloud/entity.py
@@ -1,4 +1,4 @@
-"""Generic entity for the HomematicIP Cloud component."""
+"""Generic entity for the HomematicIP Cloud integration."""
import contextlib
import logging
diff --git a/homeassistant/components/homematicip_cloud/errors.py b/homeassistant/components/homematicip_cloud/errors.py
index bbee58f7a417..dc753cf62cb4 100644
--- a/homeassistant/components/homematicip_cloud/errors.py
+++ b/homeassistant/components/homematicip_cloud/errors.py
@@ -1,4 +1,4 @@
-"""Errors for the HomematicIP Cloud component."""
+"""Errors for the HomematicIP Cloud integration."""
from homeassistant.exceptions import HomeAssistantError
diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py
index df54e669a584..65da63cd344f 100644
--- a/homeassistant/components/homematicip_cloud/hap.py
+++ b/homeassistant/components/homematicip_cloud/hap.py
@@ -1,4 +1,4 @@
-"""Access point for the HomematicIP Cloud component."""
+"""Access point for the HomematicIP Cloud integration."""
import asyncio
from collections.abc import Callable
diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py
index 06dd60bfe513..0d2124a7fed6 100644
--- a/homeassistant/components/homematicip_cloud/sensor.py
+++ b/homeassistant/components/homematicip_cloud/sensor.py
@@ -23,7 +23,6 @@ from homematicip.device import (
LightSensor,
MotionDetectorIndoor,
MotionDetectorOutdoor,
- MotionDetectorPushButton,
PassageDetector,
PresenceDetectorIndoor,
RoomControlDeviceAnalog,
@@ -217,9 +216,6 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
MotionDetectorOutdoor: lambda device: [
HomematicipIlluminanceSensor(hap, device),
],
- MotionDetectorPushButton: lambda device: [
- HomematicipIlluminanceSensor(hap, device),
- ],
PresenceDetectorIndoor: lambda device: [
HomematicipIlluminanceSensor(hap, device),
],
diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json
index a1741d5d2319..f9a56ea3db9d 100644
--- a/homeassistant/components/homewizard/manifest.json
+++ b/homeassistant/components/homewizard/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "homewizard",
"name": "HomeWizard",
- "codeowners": ["@DCSBL"],
+ "codeowners": ["@DCSBL", "@lexpostma"],
"config_flow": true,
"dhcp": [
{
diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py
index 3309e8931531..41baaf1a5823 100644
--- a/homeassistant/components/honeywell/climate.py
+++ b/homeassistant/components/honeywell/climate.py
@@ -152,10 +152,8 @@ def remove_stale_devices(
# If device_id is None an invalid device entry was
# found for this config entry. If the device_id is not
# in existing device ids it's a stale device entry.
- # Remove config entry from this device entry in either case.
- device_registry.async_update_device(
- device_entry.id, remove_config_entry_id=config_entry.entry_id
- )
+ # Remove the device entry in either case.
+ device_registry.async_remove_device(device_entry.id)
class HoneywellUSThermostat(ClimateEntity):
diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json
index 79ee0ffc91f6..6804e6ef202f 100644
--- a/homeassistant/components/honeywell/manifest.json
+++ b/homeassistant/components/honeywell/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["somecomfort"],
- "requirements": ["AIOSomecomfort==0.0.35"]
+ "requirements": ["AIOSomecomfort==0.0.38"]
}
diff --git a/homeassistant/components/honeywell_string_lights/entity.py b/homeassistant/components/honeywell_string_lights/entity.py
index 9002b8713528..983e134be29b 100644
--- a/homeassistant/components/honeywell_string_lights/entity.py
+++ b/homeassistant/components/honeywell_string_lights/entity.py
@@ -1,76 +1,24 @@
"""Common entity for Honeywell String Lights integration."""
-import logging
-from typing import override
-
+from homeassistant.components.radio_frequency import (
+ RadioFrequencyTransmitterConsumerEntity,
+)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import STATE_UNAVAILABLE
-from homeassistant.core import Event, EventStateChangedData, callback
-from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.event import async_track_state_change_event
-from .const import CONF_TRANSMITTER, DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
+from .const import DOMAIN
-class HoneywellStringLightsEntity(Entity):
+class HoneywellStringLightsEntity(RadioFrequencyTransmitterConsumerEntity):
"""Honeywell String Lights base entity."""
_attr_has_entity_name = True
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the entity."""
- self._transmitter = entry.data[CONF_TRANSMITTER]
self._attr_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Honeywell",
model="String Lights",
)
-
- @override
- async def async_added_to_hass(self) -> None:
- """Subscribe to transmitter entity state changes."""
- await super().async_added_to_hass()
-
- transmitter_entity_id = er.async_validate_entity_id(
- er.async_get(self.hass), self._transmitter
- )
-
- @callback
- def _async_transmitter_state_changed(
- event: Event[EventStateChangedData],
- ) -> None:
- """Handle transmitter entity state changes."""
- new_state = event.data["new_state"]
- transmitter_available = (
- new_state is not None and new_state.state != STATE_UNAVAILABLE
- )
- if transmitter_available != self.available:
- _LOGGER.info(
- "Transmitter %s used by %s is %s",
- transmitter_entity_id,
- self.entity_id,
- "available" if transmitter_available else "unavailable",
- )
-
- self._attr_available = transmitter_available
- self.async_write_ha_state()
-
- self.async_on_remove(
- async_track_state_change_event(
- self.hass,
- [transmitter_entity_id],
- _async_transmitter_state_changed,
- )
- )
-
- # Set initial availability based on current transmitter entity state
- transmitter_state = self.hass.states.get(transmitter_entity_id)
- self._attr_available = (
- transmitter_state is not None
- and transmitter_state.state != STATE_UNAVAILABLE
- )
diff --git a/homeassistant/components/honeywell_string_lights/light.py b/homeassistant/components/honeywell_string_lights/light.py
index 0e8dee04458b..51a45cdfbcc0 100644
--- a/homeassistant/components/honeywell_string_lights/light.py
+++ b/homeassistant/components/honeywell_string_lights/light.py
@@ -5,13 +5,13 @@ from typing import Any, override
from rf_protocols.codes.honeywell.string_lights import CODES
from homeassistant.components.light import ColorMode, LightEntity
-from homeassistant.components.radio_frequency import async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
+from .const import CONF_TRANSMITTER
from .entity import HoneywellStringLightsEntity
PARALLEL_UPDATES = 1
@@ -33,7 +33,11 @@ class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEnti
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_name = None
- _attr_should_poll = False
+
+ def __init__(self, entry: ConfigEntry) -> None:
+ """Initialize the entity."""
+ super().__init__(entry)
+ self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER]
@override
async def async_added_to_hass(self) -> None:
@@ -45,20 +49,18 @@ class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEnti
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
- await self._async_send_command("turn_on")
+ await self._async_send_rf_command("turn_on")
self._attr_is_on = True
self.async_write_ha_state()
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
- await self._async_send_command("turn_off")
+ await self._async_send_rf_command("turn_off")
self._attr_is_on = False
self.async_write_ha_state()
- async def _async_send_command(self, name: str) -> None:
+ async def _async_send_rf_command(self, name: str) -> None:
"""Load the named command and send it via the configured transmitter."""
command = await CODES.async_load_command(name)
- await async_send_command(
- self.hass, self._transmitter, command, context=self._context
- )
+ await self._send_command(command)
diff --git a/homeassistant/components/hr_energy_qube/manifest.json b/homeassistant/components/hr_energy_qube/manifest.json
index 5992fbed0793..0fe661bdbbe1 100644
--- a/homeassistant/components/hr_energy_qube/manifest.json
+++ b/homeassistant/components/hr_energy_qube/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["python_qube_heatpump"],
"quality_scale": "bronze",
- "requirements": ["python-qube-heatpump==1.11.0"]
+ "requirements": ["python-qube-heatpump==1.12.0"]
}
diff --git a/homeassistant/components/html5/issue.py b/homeassistant/components/html5/issue.py
index 66c11f5c7421..37cb84fbff7f 100644
--- a/homeassistant/components/html5/issue.py
+++ b/homeassistant/components/html5/issue.py
@@ -52,3 +52,33 @@ def deprecated_dismiss_action_call(hass: HomeAssistant) -> None:
"new_action": "html5.dismiss_message",
},
)
+
+
+@callback
+def deprecated_event_bus(hass: HomeAssistant, event: str) -> None:
+ """Raise a deprecation issue for listeners on the event bus."""
+
+ if listeners := hass.bus.async_listeners().get(event):
+ async_create_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_event_bus_{event}",
+ breaks_in_ha_version="2027.2.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_event_bus",
+ translation_placeholders={
+ "event": event,
+ "listeners": str(listeners),
+ "example_yaml": """```yaml
+triggers:
+ - trigger: event.received
+ target:
+ entity_id: event.my_device
+ options:
+ event_type:
+ - received
+```
+""",
+ },
+ )
diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py
index 00e5762b3ddc..1e6c1b743035 100644
--- a/homeassistant/components/html5/notify.py
+++ b/homeassistant/components/html5/notify.py
@@ -60,7 +60,11 @@ from .const import (
SERVICE_DISMISS,
)
from .entity import HTML5Entity, Registration
-from .issue import deprecated_dismiss_action_call, deprecated_notify_action_call
+from .issue import (
+ deprecated_dismiss_action_call,
+ deprecated_event_bus,
+ deprecated_notify_action_call,
+)
_LOGGER = logging.getLogger(__name__)
@@ -409,6 +413,9 @@ class HTML5PushCallbackView(HomeAssistantView):
event_payload[ATTR_TYPE],
event_payload,
)
+
+ deprecated_event_bus(hass, event_name)
+
return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]})
diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json
index 3dea89e6ead2..1bc8941c65e6 100644
--- a/homeassistant/components/html5/strings.json
+++ b/homeassistant/components/html5/strings.json
@@ -55,6 +55,10 @@
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action}` action instead.",
"title": "[%key:component::html5::issues::deprecated_notify_action::title%]"
},
+ "deprecated_event_bus": {
+ "description": "Detected **{listeners}** listener(s) for the event `{event}`.\n\nThe HTML5 Push Notifications integration firing events on the event bus is deprecated and this functionality will be removed in a future release.\n\nPlease update your automations and scripts to use the event entities instead.\n\n## Example automation:\n\n{example_yaml}",
+ "title": "Detected use of deprecated event {event}"
+ },
"deprecated_notify_action": {
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action_1}` or `{new_action_2}` actions instead.",
"title": "Detected use of deprecated action {action}"
diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py
index 247da3e1b6d2..04b622df32bb 100644
--- a/homeassistant/components/http/__init__.py
+++ b/homeassistant/components/http/__init__.py
@@ -12,7 +12,7 @@ from pathlib import Path
import socket
import ssl
from tempfile import NamedTemporaryFile
-from typing import Any, Final, TypedDict, cast, override
+from typing import Any, Final, cast, override
from aiohttp import web
from aiohttp.abc import AbstractStreamWriter
@@ -33,11 +33,10 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
HASSIO_USER_NAME,
- SERVER_PORT,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.http import (
KEY_ALLOW_CONFIGURED_CORS,
@@ -60,7 +59,36 @@ from homeassistant.util.json import json_loads
from .auth import async_setup_auth
from .ban import setup_bans
-from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401
+from .config import (
+ _DEFAULT_CONFIG,
+ ConfData,
+ HTTPConfigStore,
+ async_get_and_load_store,
+ async_load_config,
+ default_server_port,
+)
+from .const import ( # noqa: F401
+ CONF_BASE_URL,
+ CONF_CORS_ORIGINS,
+ CONF_IP_BAN_ENABLED,
+ CONF_LOGIN_ATTEMPTS_THRESHOLD,
+ CONF_SERVER_HOST,
+ CONF_SERVER_PORT,
+ CONF_SSL_CERTIFICATE,
+ CONF_SSL_KEY,
+ CONF_SSL_PEER_CERTIFICATE,
+ CONF_SSL_PROFILE,
+ CONF_TRUSTED_PROXIES,
+ CONF_USE_X_FORWARDED_FOR,
+ CONF_USE_X_FRAME_OPTIONS,
+ DEFAULT_CORS,
+ DOMAIN,
+ KEY_HASS_REFRESH_TOKEN_ID,
+ KEY_HASS_USER,
+ NO_LOGIN_ATTEMPT_THRESHOLD,
+ SSL_INTERMEDIATE,
+ SSL_MODERN,
+)
from .cors import setup_cors
from .decorators import require_admin # noqa: F401
from .forwarded import async_setup_forwarded
@@ -68,40 +96,15 @@ from .headers import setup_headers
from .request_context import setup_request_context
from .security_filter import setup_security_filter
from .static import CACHE_HEADERS, CachingStaticResource
-from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite
-
-CONF_SERVER_HOST: Final = "server_host"
-CONF_SERVER_PORT: Final = "server_port"
-CONF_BASE_URL: Final = "base_url"
-CONF_SSL_CERTIFICATE: Final = "ssl_certificate"
-CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate"
-CONF_SSL_KEY: Final = "ssl_key"
-CONF_CORS_ORIGINS: Final = "cors_allowed_origins"
-CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for"
-CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options"
-CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
-CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
-CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
-CONF_SSL_PROFILE: Final = "ssl_profile"
-
-SSL_MODERN: Final = "modern"
-SSL_INTERMEDIATE: Final = "intermediate"
+from .web_runner import HomeAssistantUnixSite
_LOGGER: Final = logging.getLogger(__name__)
DEFAULT_DEVELOPMENT: Final = "0"
-# Cast to be able to load custom cards.
-# My to be able to check url and version info.
-DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"]
-NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1
MAX_CLIENT_SIZE: Final = 1024**2 * 16
MAX_LINE_SIZE: Final = 24570
-STORAGE_KEY: Final = DOMAIN
-STORAGE_VERSION: Final = 1
-SAVE_DELAY: Final = 180
-
_HAS_IPV6 = hasattr(socket, "AF_INET6")
_DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"]
@@ -112,7 +115,7 @@ HTTP_SCHEMA: Final = vol.All(
vol.Optional(CONF_SERVER_HOST): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
),
- vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
+ vol.Optional(CONF_SERVER_PORT, default=default_server_port): cv.port,
vol.Optional(CONF_BASE_URL): cv.string,
vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile,
@@ -154,30 +157,6 @@ _STATIC_CLASSES = {
}
-class ConfData(TypedDict, total=False):
- """Typed dict for config data."""
-
- server_host: list[str]
- server_port: int
- base_url: str
- ssl_certificate: str
- ssl_peer_certificate: str
- ssl_key: str
- cors_allowed_origins: list[str]
- use_x_forwarded_for: bool
- use_x_frame_options: bool
- trusted_proxies: list[IPv4Network | IPv6Network]
- login_attempts_threshold: int
- ip_ban_enabled: bool
- ssl_profile: str
-
-
-async def async_get_last_config(hass: HomeAssistant) -> dict[str, Any] | None:
- """Return the last known working config."""
- store = storage.Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
- return await store.async_load()
-
-
class ApiConfig:
"""Configuration settings for API server."""
@@ -195,16 +174,143 @@ class ApiConfig:
self.use_ssl = use_ssl
+async def _async_fallback_config(
+ hass: HomeAssistant,
+ store: HTTPConfigStore,
+ conf: ConfData,
+ err: HomeAssistantError | OSError,
+) -> ConfData:
+ """Return the next config to try after ``conf`` could not be applied.
+
+ Implements the fallback chain pending -> stable -> default config, where
+ the last step is only taken in recovery mode. Raises when there is no
+ (acceptable) fallback left, failing setup: on a normal boot this
+ activates recovery mode, in recovery mode it makes the failure visible
+ to the outside (e.g. the Supervisor rolls back a Core update whose API
+ does not come up).
+ """
+ if store.revert_deadline is not None:
+ # An unconfirmed pending config is under trial and cannot even be
+ # applied, so it is known to be bad: revert to the stable config
+ # right away and continue this same start with it, instead of
+ # waiting out the trial window and restarting.
+ _LOGGER.error(
+ "The new HTTP configuration could not be applied, reverting to "
+ "the previous configuration: %s",
+ err,
+ )
+ await store.async_abort_trial()
+ return store.stable
+
+ if (
+ # In normal mode, fail setup so recovery mode can take over with a
+ # reachable configuration.
+ not hass.config.recovery_mode
+ # The chain is exhausted; nothing left to fall back to.
+ or conf is _DEFAULT_CONFIG
+ # With peer certificate verification configured, connections must
+ # never be accepted without a verified client certificate; there is
+ # no acceptable fallback config.
+ or CONF_SSL_PEER_CERTIFICATE in conf
+ ):
+ # An unusable SSL configuration already carries a descriptive
+ # HomeAssistantError.
+ if isinstance(err, HomeAssistantError):
+ raise err
+ raise HomeAssistantError(
+ f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}"
+ ) from err
+
+ # The config cannot be applied in recovery mode; fall back to the
+ # default config so the recovery UI stays reachable.
+ _LOGGER.error(
+ "The HTTP configuration could not be applied in recovery mode, "
+ "falling back to the default configuration: %s",
+ err,
+ )
+ return _DEFAULT_CONFIG
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HTTP API and debug interface."""
# Late import to ensure isal is updated before
# we import aiohttp_fast_zlib
(await async_import_module(hass, "aiohttp_fast_zlib")).enable()
- conf: ConfData | None = config.get(DOMAIN)
+ # Deferred import: websocket_api declares http as its manifest
+ # dependency and imports back into this package at module load
+ # (websocket_api/http.py -> homeassistant.components.http). A top-level
+ # import of .websocket_api here would re-enter the still-loading
+ # websocket_api package and fail when applying its decorators
+ # (e.g. @websocket_api.require_admin).
+ websocket_api_module = await async_import_module(
+ hass, "homeassistant.components.http.websocket_api"
+ )
- if conf is None:
- conf = cast(ConfData, HTTP_SCHEMA({}))
+ conf = await async_load_config(hass, config)
+
+ websocket_api_module.async_register_websocket_commands(hass)
+
+ supervisor_unix_socket_path: Path | None = None
+ if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"):
+ socket_path = Path(socket_env)
+ if socket_path.is_absolute():
+ supervisor_unix_socket_path = socket_path
+ else:
+ _LOGGER.error(
+ "Invalid Supervisor Unix socket path %s: path must be absolute",
+ socket_env,
+ )
+
+ def _make_server(conf: ConfData) -> HomeAssistantHTTP:
+ return HomeAssistantHTTP(
+ hass,
+ server_host=conf.get(CONF_SERVER_HOST, _DEFAULT_BIND),
+ server_port=conf[CONF_SERVER_PORT],
+ ssl_certificate=conf.get(CONF_SSL_CERTIFICATE),
+ ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE),
+ ssl_key=conf.get(CONF_SSL_KEY),
+ # The loaded config stores trusted proxies as strings
+ # (JSON-serializable); the forwarded middleware needs
+ # IPv4Network/IPv6Network objects.
+ trusted_proxies=[
+ ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or []
+ ],
+ ssl_profile=conf[CONF_SSL_PROFILE],
+ supervisor_unix_socket_path=supervisor_unix_socket_path,
+ )
+
+ server = _make_server(conf)
+ trial_reverted = False
+ while True:
+ try:
+ await server.async_bind()
+ except (HomeAssistantError, OSError) as err:
+ store = await async_get_and_load_store(hass)
+ trial_reverted = store.revert_deadline is not None
+ conf = await _async_fallback_config(hass, store, conf, err)
+ server = _make_server(conf)
+ continue
+ if trial_reverted:
+ _LOGGER.warning(
+ "The previous HTTP configuration has been restored (server port %d)",
+ conf[CONF_SERVER_PORT],
+ )
+ break
+
+ # Created only after the fallback chain succeeded: if setup fails above,
+ # an already running task would be left behind unawaited.
+ source_ip_task = create_eager_task(async_get_source_ip(hass))
+
+ async def stop_server(event: Event) -> None:
+ """Stop the server."""
+ await server.stop()
+
+ # Register the stop listener right away, not only once serving starts:
+ # sockets are already bound, and if the remainder of startup fails the
+ # recovery-mode teardown (which fires the stop event) must release them,
+ # or the recovery boot cannot bind the same address again.
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
if CONF_SERVER_HOST in conf and is_hassio(hass):
issue_id = "server_host_deprecated_hassio"
@@ -221,59 +327,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND)
server_port = conf[CONF_SERVER_PORT]
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
- ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)
- ssl_key = conf.get(CONF_SSL_KEY)
- cors_origins = conf[CONF_CORS_ORIGINS]
- use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False)
- use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS]
- trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or []
- is_ban_enabled = conf[CONF_IP_BAN_ENABLED]
- login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD]
- ssl_profile = conf[CONF_SSL_PROFILE]
- source_ip_task = create_eager_task(async_get_source_ip(hass))
-
- supervisor_unix_socket_path: Path | None = None
- if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"):
- socket_path = Path(socket_env)
- if socket_path.is_absolute():
- supervisor_unix_socket_path = socket_path
- else:
- _LOGGER.error(
- "Invalid Supervisor Unix socket path %s: path must be absolute",
- socket_env,
- )
-
- server = HomeAssistantHTTP(
- hass,
- server_host=server_host,
- server_port=server_port,
- ssl_certificate=ssl_certificate,
- ssl_peer_certificate=ssl_peer_certificate,
- ssl_key=ssl_key,
- trusted_proxies=trusted_proxies,
- ssl_profile=ssl_profile,
- supervisor_unix_socket_path=supervisor_unix_socket_path,
- )
await server.async_initialize(
- cors_origins=cors_origins,
- use_x_forwarded_for=use_x_forwarded_for,
- login_threshold=login_threshold,
- is_ban_enabled=is_ban_enabled,
- use_x_frame_options=use_x_frame_options,
+ cors_origins=conf[CONF_CORS_ORIGINS],
+ use_x_forwarded_for=conf.get(CONF_USE_X_FORWARDED_FOR, False),
+ login_threshold=conf[CONF_LOGIN_ATTEMPTS_THRESHOLD],
+ is_ban_enabled=conf[CONF_IP_BAN_ENABLED],
+ use_x_frame_options=conf[CONF_USE_X_FRAME_OPTIONS],
)
- async def stop_server(event: Event) -> None:
- """Stop the server."""
- await server.stop()
-
async def start_server(*_: Any) -> None:
"""Start the server."""
with async_start_setup(hass, integration="http", phase=SetupPhases.SETUP):
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
- # We already checked it's not None.
- assert conf is not None
- await start_http_server_and_save_config(hass, dict(conf), server)
+ await server.start()
async_when_setup_or_start(hass, "frontend", start_server)
@@ -414,9 +480,51 @@ class HomeAssistantHTTP:
self.ssl_profile = ssl_profile
self.supervisor_unix_socket_path = supervisor_unix_socket_path
self.runner: web.AppRunner | None = None
- self.site: HomeAssistantTCPSite | None = None
self.supervisor_site: HomeAssistantUnixSite | None = None
self.context: ssl.SSLContext | None = None
+ self._server: asyncio.Server | None = None
+
+ async def async_bind(self) -> None:
+ """Create the SSL context and the server, binding its sockets.
+
+ Called during setup so that an unusable configuration surfaces before
+ it is applied; serving starts later in ``start()``. Raises
+ ``HomeAssistantError`` if the SSL configuration is unusable and
+ ``OSError`` if the configured address cannot be bound.
+ """
+ if self.ssl_certificate:
+ self.context = await self.hass.async_add_executor_job(
+ self._create_ssl_context
+ )
+ self._server = await self._async_create_server()
+
+ async def _async_create_server(self) -> asyncio.Server:
+ """Create the (not yet serving) HTTP server, binding its sockets."""
+ try:
+ return await self.hass.loop.create_server(
+ self._make_protocol,
+ self.server_host if self.server_host is not None else _DEFAULT_BIND,
+ self.server_port,
+ ssl=self.context,
+ backlog=128,
+ start_serving=False,
+ )
+ except UnicodeError as err:
+ # create_server() raises UnicodeError for hosts the IDNA codec
+ # cannot encode (e.g. a label longer than 63 characters);
+ # normalize to OSError so callers only need to handle one error
+ # type.
+ raise OSError(f"error while resolving host: {err}") from err
+
+ def _make_protocol(self) -> RequestHandler:
+ """Create a protocol instance for an accepted connection.
+
+ Connections are only accepted once ``start()`` has run, so the
+ runner is set up by the time this is called.
+ """
+ runner = self.runner
+ assert runner is not None and runner.server is not None
+ return runner.server()
async def async_initialize(
self,
@@ -447,11 +555,6 @@ class HomeAssistantHTTP:
setup_headers(self.app, use_x_frame_options)
setup_cors(self.app, cors_origins)
- if self.ssl_certificate:
- self.context = await self.hass.async_add_executor_job(
- self._create_ssl_context
- )
-
def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None:
"""Register a view with the WSGI server.
@@ -492,7 +595,7 @@ class HomeAssistantHTTP:
async def redirect(request: web.Request) -> web.StreamResponse:
"""Redirect to location."""
# Should be instance of aiohttp.web_exceptions._HTTPMove.
- raise redirect_exc(redirect_to) # type: ignore[arg-type,misc]
+ raise redirect_exc(redirect_to) # type: ignore[arg-type,call-arg]
self.app[KEY_ALLOW_CONFIGURED_CORS](
self.app.router.add_route("GET", url, redirect)
@@ -572,12 +675,13 @@ class HomeAssistantHTTP:
)
context = None
else:
+ # Fall through: a configured peer certificate must still be
+ # enforced on the emergency context.
_LOGGER.critical(
"Home Assistant is running in recovery mode with an emergency self"
" signed ssl certificate because the configured SSL certificate was"
" not usable"
)
- return context
if self.ssl_peer_certificate:
if context is None:
@@ -587,7 +691,15 @@ class HomeAssistantHTTP:
)
context.verify_mode = ssl.CERT_REQUIRED
- context.load_verify_locations(self.ssl_peer_certificate)
+ try:
+ context.load_verify_locations(self.ssl_peer_certificate)
+ except OSError as error:
+ # Raise HomeAssistantError so the caller can tell an unusable
+ # SSL configuration apart from a socket bind failure (OSError).
+ raise HomeAssistantError(
+ f"Could not use SSL peer certificate from"
+ f" {self.ssl_peer_certificate}: {error}"
+ ) from error
return context
@@ -680,15 +792,10 @@ class HomeAssistantHTTP:
)
await self.runner.setup()
- self.site = HomeAssistantTCPSite(
- self.runner, self.server_host, self.server_port, ssl_context=self.context
- )
- try:
- await self.site.start()
- except OSError as error:
- _LOGGER.error(
- "Failed to create HTTP server at port %d: %s", self.server_port, error
- )
+ # Setup either binds the server or fails, so it is always available
+ # here.
+ assert self._server is not None
+ await self._server.start_serving()
_LOGGER.info("Now listening on port %d", self.server_port)
@@ -707,27 +814,8 @@ class HomeAssistantHTTP:
self.supervisor_unix_socket_path,
err,
)
- if self.site is not None:
- await self.site.stop()
+ if self._server is not None:
+ self._server.close()
+ await self._server.wait_closed()
if self.runner is not None:
await self.runner.cleanup()
-
-
-async def start_http_server_and_save_config(
- hass: HomeAssistant, conf: dict, server: HomeAssistantHTTP
-) -> None:
- """Startup the http server and save the config."""
- await server.start()
-
- # If we are set up successful, we store the HTTP settings for recovery mode.
- store: storage.Store[dict[str, Any]] = storage.Store(
- hass, STORAGE_VERSION, STORAGE_KEY
- )
-
- if CONF_TRUSTED_PROXIES in conf:
- conf[CONF_TRUSTED_PROXIES] = [
- str(cast(IPv4Network | IPv6Network, ip).network_address)
- for ip in conf[CONF_TRUSTED_PROXIES]
- ]
-
- store.async_delay_save(lambda: conf, SAVE_DELAY)
diff --git a/homeassistant/components/http/config.py b/homeassistant/components/http/config.py
new file mode 100644
index 000000000000..3406ad4d793f
--- /dev/null
+++ b/homeassistant/components/http/config.py
@@ -0,0 +1,425 @@
+"""User-managed HTTP configuration store."""
+
+import asyncio
+from datetime import datetime, timedelta
+from ipaddress import ip_network
+import logging
+import os
+from typing import Any, Final, TypedDict, cast, override
+
+import voluptuous as vol
+
+from homeassistant.const import SERVER_PORT
+from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
+from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.storage import Store
+from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import dt as dt_util
+from homeassistant.util.hass_dict import HassKey
+
+from .const import (
+ CONF_BASE_URL,
+ CONF_CORS_ORIGINS,
+ CONF_IP_BAN_ENABLED,
+ CONF_LOGIN_ATTEMPTS_THRESHOLD,
+ CONF_SERVER_HOST,
+ CONF_SERVER_PORT,
+ CONF_SSL_CERTIFICATE,
+ CONF_SSL_KEY,
+ CONF_SSL_PEER_CERTIFICATE,
+ CONF_SSL_PROFILE,
+ CONF_TRUSTED_PROXIES,
+ CONF_USE_X_FORWARDED_FOR,
+ CONF_USE_X_FRAME_OPTIONS,
+ DEFAULT_CORS,
+ DOMAIN,
+ ENV_SETUP_PORT,
+ NO_LOGIN_ATTEMPT_THRESHOLD,
+ SSL_INTERMEDIATE,
+ SSL_MODERN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def default_server_port() -> int:
+ """Return the default HTTP server port.
+
+ The built-in default port can be overridden via the
+ ``SETUP_PORT`` environment variable. An invalid value is ignored in favor
+ of the built-in default.
+ """
+ if (env_value := os.environ.get(ENV_SETUP_PORT)) is None:
+ return SERVER_PORT
+ try:
+ return cast(int, cv.port(env_value))
+ except vol.Invalid:
+ _LOGGER.warning(
+ "Invalid port %r in %s environment variable; falling back to %s",
+ env_value,
+ ENV_SETUP_PORT,
+ SERVER_PORT,
+ )
+ return SERVER_PORT
+
+
+STORAGE_KEY: Final = DOMAIN
+STORAGE_VERSION: Final = 2
+
+KEY_STABLE: Final = "stable"
+KEY_PENDING: Final = "pending"
+KEY_YAML_MIGRATION_DONE: Final = "yaml_migration_done"
+
+AUTO_REVERT_DELAY: Final = timedelta(minutes=5)
+
+DATA_STORE: HassKey[HTTPConfigStore] = HassKey(STORAGE_KEY)
+
+
+class ConfData(TypedDict, total=False):
+ """Typed dict for the validated HTTP config (matches ``HTTP_STORAGE_SCHEMA``)."""
+
+ server_host: list[str]
+ server_port: int
+ ssl_certificate: str
+ ssl_peer_certificate: str
+ ssl_key: str
+ cors_allowed_origins: list[str]
+ use_x_forwarded_for: bool
+ trusted_proxies: list[str]
+ login_attempts_threshold: int
+ ip_ban_enabled: bool
+ ssl_profile: str
+ use_x_frame_options: bool
+
+
+class _HTTPStoreData(TypedDict):
+ """Data structure for HTTP config storage."""
+
+ stable: ConfData
+ pending: ConfData | None
+ yaml_migration_done: bool
+
+
+def _ip_network_str(value: Any) -> str:
+ """Validate the value is a valid IP network and return its string form."""
+ return str(ip_network(value))
+
+
+HTTP_STORAGE_SCHEMA: Final = vol.Schema(
+ {
+ # YAML used to allow base_url (deprecated); strip it on the way in so
+ # the stored config never contains it.
+ vol.Remove(CONF_BASE_URL): object,
+ vol.Optional(CONF_SERVER_HOST): vol.All(
+ cv.ensure_list, vol.Length(min=1), [cv.string]
+ ),
+ vol.Optional(CONF_SERVER_PORT, default=default_server_port): cv.port,
+ vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
+ vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile,
+ vol.Optional(CONF_SSL_KEY): cv.isfile,
+ vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All(
+ cv.ensure_list, [cv.string]
+ ),
+ vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean,
+ vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All(
+ cv.ensure_list, [_ip_network_str]
+ ),
+ vol.Optional(
+ CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD
+ ): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD),
+ vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean,
+ vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In(
+ [SSL_INTERMEDIATE, SSL_MODERN]
+ ),
+ vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
+ }
+)
+_DEFAULT_CONFIG: Final[ConfData] = cast(ConfData, HTTP_STORAGE_SCHEMA({}))
+
+
+async def async_load_config(hass: HomeAssistant, config: ConfigType) -> ConfData:
+ """Load the HTTP config to apply on this startup.
+
+ YAML config is only migrated once. Subsequent boots will ignore YAML and
+ use the store exclusively.
+
+ Resolution order:
+ - Recovery mode: always use ``stable`` so HA stays reachable after a bad
+ config; YAML is ignored entirely (any pending YAML migration is
+ deferred to the next normal boot).
+ - Normal mode: prefer ``pending`` if set, otherwise ``stable``.
+ """
+ store = await async_get_and_load_store(hass)
+ if hass.config.recovery_mode:
+ _LOGGER.info("Recovery mode active; using stable HTTP config")
+ return store.stable
+
+ yaml_conf: ConfData | None = config.get(DOMAIN)
+ if store.yaml_migration_done:
+ if yaml_conf is not None:
+ # YAML is still present after migration completed; surface a repair
+ # issue so the user knows their YAML is being ignored.
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "yaml_still_present_after_migration",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="yaml_still_present_after_migration",
+ )
+ else:
+ # Clear any leftover deprecation issues if YAML was removed after migration.
+ ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml_import_error")
+ ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml")
+ ir.async_delete_issue(hass, DOMAIN, "yaml_still_present_after_migration")
+ else:
+ # Migrate YAML to storage and use it directly for this start. The
+ # migration function also marks the migration as done so future
+ # starts will ignore any remaining YAML.
+ conf_in_yaml = yaml_conf is not None
+ if yaml_conf is None:
+ yaml_conf = cast(ConfData, HTTP_STORAGE_SCHEMA({}))
+
+ try:
+ await store.async_migrate_yaml(yaml_conf)
+ except Exception:
+ _LOGGER.exception("Failed to migrate HTTP YAML configuration to storage")
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_import_error",
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="deprecated_yaml_import_error",
+ )
+ else:
+ if conf_in_yaml:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml",
+ breaks_in_ha_version="2027.6.0",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ )
+
+ if store.pending is not None:
+ _LOGGER.info("Using pending HTTP config")
+ store.async_schedule_revert_to_stable()
+ return store.pending
+
+ _LOGGER.info("Using stable HTTP config")
+ return store.stable
+
+
+async def async_get_and_load_store(hass: HomeAssistant) -> HTTPConfigStore:
+ """Return the singleton HTTP config store and load it."""
+ if (store := hass.data.get(DATA_STORE)) is None:
+ store = HTTPConfigStore(hass)
+ hass.data[DATA_STORE] = store
+ await store.async_load()
+ return store
+
+
+class HTTPConfigStore:
+ """Persist HTTP config as a stable/pending pair.
+
+ ``stable`` holds the last config the user confirmed as working;
+ ``pending`` holds an unconfirmed config the user wants to try on
+ the next start. Normal startup prefers ``pending`` so the new
+ config gets exercised; recovery mode falls back to ``stable`` so
+ Home Assistant can still come up after a bad config.
+ """
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the store."""
+ self._hass = hass
+ self._store = _HTTPStore(
+ hass,
+ STORAGE_VERSION,
+ STORAGE_KEY,
+ private=True,
+ atomic_writes=True,
+ )
+ self._stable: ConfData = _DEFAULT_CONFIG
+ self._pending: ConfData | None = None
+ self._yaml_migration_done = False
+ self._loaded = False
+ self._load_lock = asyncio.Lock()
+ self._revert_unsub: CALLBACK_TYPE | None = None
+ self._revert_deadline: datetime | None = None
+
+ @property
+ def stable(self) -> ConfData:
+ """Return the last confirmed-working config."""
+ return self._stable
+
+ @property
+ def pending(self) -> ConfData | None:
+ """Return the unconfirmed config awaiting promotion, if any."""
+ return self._pending
+
+ @property
+ def revert_deadline(self) -> datetime | None:
+ """Return when the pending config auto-reverts to stable, if scheduled."""
+ return self._revert_deadline
+
+ @property
+ def yaml_migration_done(self) -> bool:
+ """Return whether the YAML migration has been completed."""
+ return self._yaml_migration_done
+
+ async def async_load(self) -> None:
+ """Load the stable and pending configs from disk."""
+ if self._loaded:
+ return
+ async with self._load_lock:
+ if self._loaded:
+ # Another coroutine may have loaded the config while we were waiting
+ # for the lock; check again to avoid unnecessary disk I/O.
+ return # type: ignore[unreachable]
+ raw = await self._store.async_load()
+ if raw is not None:
+ self._stable = raw[KEY_STABLE]
+ self._pending = raw[KEY_PENDING]
+ self._yaml_migration_done = raw[KEY_YAML_MIGRATION_DONE]
+ self._loaded = True
+
+ async def async_set_pending(self, config: ConfData | None) -> None:
+ """Set (or clear) the pending config."""
+ await self.async_load()
+ if config == self.stable:
+ # No need to save a pending config that is the same as stable.
+ config = None
+ self._pending = config
+ await self._async_persist()
+
+ async def async_promote_pending(self) -> None:
+ """Promote the pending config to stable.
+
+ Raises ``HomeAssistantError`` if there is nothing to promote.
+ """
+ await self.async_load()
+ if self._pending is None:
+ raise HomeAssistantError("No pending HTTP config to promote")
+ self._stable = self._pending
+ self._pending = None
+ # The config is now confirmed; no need to revert it anymore.
+ self._async_cancel_revert()
+ await self._async_persist()
+
+ @callback
+ def async_schedule_revert_to_stable(self) -> None:
+ """Schedule reverting the pending config back to stable.
+
+ Loading a pending config is a trial. If the user does not promote it
+ within ``AUTO_REVERT_DELAY`` (e.g. because the new config made Home
+ Assistant unreachable), automatically clear it and restart so the last
+ known-good stable config is restored.
+ """
+ self._async_cancel_revert()
+ self._revert_deadline = dt_util.utcnow() + AUTO_REVERT_DELAY
+ self._revert_unsub = async_call_later(
+ self._hass,
+ AUTO_REVERT_DELAY,
+ HassJob(
+ self._async_revert_to_stable,
+ "http config auto-revert",
+ cancel_on_shutdown=True,
+ ),
+ )
+
+ @callback
+ def _async_cancel_revert(self) -> None:
+ """Cancel a scheduled revert, if any.
+
+ Also clears the deadline so ``revert_deadline`` no longer reports a
+ revert that will not happen (e.g. after the config is promoted).
+ """
+ if self._revert_unsub is not None:
+ self._revert_unsub()
+ self._revert_unsub = None
+ self._revert_deadline = None
+
+ async def _async_revert_to_stable(self, _now: datetime) -> None:
+ """Clear the unconfirmed pending config and restart to apply stable."""
+ self._async_cancel_revert()
+ if self._pending is None:
+ return
+ _LOGGER.warning(
+ "Pending HTTP config was not confirmed within %s; reverting to the "
+ "stable config and restarting",
+ AUTO_REVERT_DELAY,
+ )
+ self._pending = None
+ await self._async_persist()
+ # Imported here to avoid a circular import at module load time.
+ from homeassistant.components.homeassistant import ( # noqa: PLC0415
+ DOMAIN as HASS_DOMAIN,
+ SERVICE_HOMEASSISTANT_RESTART,
+ )
+
+ await self._hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART)
+
+ async def async_abort_trial(self) -> None:
+ """Abort the running pending-config trial and reinstate stable.
+
+ Called during setup when the pending config cannot be applied at all
+ (its address cannot be bound or its SSL configuration is unusable).
+ Clears the pending config so this and future starts use stable.
+ """
+ await self.async_load()
+ self._async_cancel_revert()
+ self._pending = None
+ await self._async_persist()
+
+ async def async_migrate_yaml(self, config: ConfData) -> None:
+ """Migrate YAML config to storage as pending if not the same as the config used for recovery."""
+ await self.async_load()
+ validated_config = cast(ConfData, HTTP_STORAGE_SCHEMA(config))
+ self._pending = None if validated_config == self._stable else validated_config
+ self._yaml_migration_done = True
+ await self._async_persist()
+
+ async def _async_persist(self) -> None:
+ """Write the current state to disk (or remove the file if empty)."""
+ await self._store.async_save(
+ {
+ KEY_STABLE: self._stable,
+ KEY_PENDING: self._pending,
+ KEY_YAML_MIGRATION_DONE: self._yaml_migration_done,
+ }
+ )
+
+
+class _HTTPStore(Store[_HTTPStoreData]):
+ """Http store."""
+
+ @override
+ async def _async_migrate_func(
+ self,
+ old_major_version: int,
+ old_minor_version: int,
+ old_data: dict[str, Any],
+ ) -> dict[str, Any]:
+ if old_major_version == 1:
+ # Run the v1 payload through the storage schema so the v2 ``stable``
+ # slot is well-formed (all keys present, values normalised) and the
+ # load step can rely on direct key access.
+ try:
+ stable = HTTP_STORAGE_SCHEMA(old_data)
+ except vol.Invalid:
+ _LOGGER.warning(
+ "Discarding invalid v1 HTTP config during migration; "
+ "falling back to defaults"
+ )
+ stable = _DEFAULT_CONFIG
+ return {
+ KEY_STABLE: stable,
+ KEY_PENDING: None,
+ KEY_YAML_MIGRATION_DONE: False,
+ }
+ return old_data
diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py
index c89751a62aff..415d9237e4a8 100644
--- a/homeassistant/components/http/const.py
+++ b/homeassistant/components/http/const.py
@@ -10,15 +10,54 @@ DOMAIN: Final = "http"
KEY_HASS_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
+KEY_SUPERVISOR_UNIX_SOCKET: Final = "ha_supervisor_unix_socket"
+
+CONF_SERVER_HOST: Final = "server_host"
+CONF_SERVER_PORT: Final = "server_port"
+CONF_BASE_URL: Final = "base_url"
+CONF_SSL_CERTIFICATE: Final = "ssl_certificate"
+CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate"
+CONF_SSL_KEY: Final = "ssl_key"
+CONF_CORS_ORIGINS: Final = "cors_allowed_origins"
+CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for"
+CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options"
+CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
+CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
+CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
+CONF_SSL_PROFILE: Final = "ssl_profile"
+
+SSL_MODERN: Final = "modern"
+SSL_INTERMEDIATE: Final = "intermediate"
+
+ENV_SETUP_PORT: Final = "SETUP_PORT"
+
+# Cast to be able to load custom cards.
+# My to be able to check url and version info.
+DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"]
+NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1
+
+ATTR_CONFIG = "config"
def is_supervisor_unix_socket_request(request: Request) -> bool:
- """Check if request arrived over the Supervisor Unix socket."""
- if (transport := request.transport) is None:
- return False
- if (http := request.app[KEY_HASS].http) is None or (
- supervisor_path := http.supervisor_unix_socket_path
- ) is None:
- return False
- sockname: str | None = transport.get_extra_info("sockname")
- return sockname == str(supervisor_path)
+ """Check if request arrived over the Supervisor Unix socket.
+
+ The result is cached on the request since it is checked by both the ban
+ and auth middlewares.
+ """
+ cached: bool | None = request.get(KEY_SUPERVISOR_UNIX_SOCKET)
+ if cached is not None:
+ return cached
+ # Cheapest check first: without a configured socket path this can never be
+ # a Supervisor Unix socket request, so we avoid probing the transport.
+ if (
+ (http := request.app[KEY_HASS].http) is None
+ or (supervisor_path := http.supervisor_unix_socket_path) is None
+ or (transport := request.transport) is None
+ ):
+ result = False
+ else:
+ sockname: str | None = transport.get_extra_info("sockname")
+ result = sockname == str(supervisor_path)
+ request[KEY_SUPERVISOR_UNIX_SOCKET] = result
+ return result
diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py
index 98df4a815d3e..a1a7e23423f0 100644
--- a/homeassistant/components/http/security_filter.py
+++ b/homeassistant/components/http/security_filter.py
@@ -55,11 +55,17 @@ def setup_security_filter(app: Application) -> None:
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
"""Process request and block commonly known exploit attempts."""
- path_with_query_string = f"{request.path}?{request.query_string}"
+ query_string = request.query_string
+ # Most requests (WebSocket/API traffic) have no query string; avoid the
+ # concat and only scan the path in that case.
+ if query_string:
+ path_with_query_string = f"{request.path}?{query_string}"
+ else:
+ path_with_query_string = request.path
for unsafe_byte in UNSAFE_URL_BYTES:
if unsafe_byte in path_with_query_string:
- if unsafe_byte in request.query_string:
+ if unsafe_byte in query_string:
_LOGGER.warning(
"Filtered a request with unsafe byte query string: %s",
request.raw_path,
@@ -75,7 +81,7 @@ def setup_security_filter(app: Application) -> None:
# Check the full path with query string first, if its
# a hit, than check just the query string to give a more
# specific warning.
- if FILTERS.search(_recursive_unquote(request.query_string)):
+ if FILTERS.search(_recursive_unquote(query_string)):
_LOGGER.warning(
"Filtered a request with a potential harmful query string: %s",
request.raw_path,
diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json
index b74cfd457b22..2ef22ae18052 100644
--- a/homeassistant/components/http/strings.json
+++ b/homeassistant/components/http/strings.json
@@ -1,5 +1,13 @@
{
"issues": {
+ "deprecated_yaml": {
+ "description": "Your existing HTTP configuration from `configuration.yaml` has been imported. The `http` integration is now configured from the UI under **Settings** > **System** > **Network**.\n\nPlease remove the `http:` block from your `configuration.yaml` and restart Home Assistant.",
+ "title": "The HTTP YAML configuration is deprecated"
+ },
+ "deprecated_yaml_import_error": {
+ "description": "Migrating the `http` configuration from `configuration.yaml` to the integration's storage failed. Please check the logs for details and configure the `http` integration from the UI under **Settings** > **System** > **Network**.",
+ "title": "Failed to import HTTP YAML configuration"
+ },
"server_host_deprecated_hassio": {
"description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
@@ -7,6 +15,10 @@
"ssl_configured_without_configured_urls": {
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
"title": "SSL is configured without an external URL or internal URL"
+ },
+ "yaml_still_present_after_migration": {
+ "description": "The HTTP configuration in `configuration.yaml` has already been migrated and is now being ignored. Please remove the `http:` block from your `configuration.yaml`. Manage the HTTP configuration from the UI under **Settings** > **System** > **Network**.",
+ "title": "HTTP YAML configuration is ignored after migration"
}
}
}
diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py
index 0348021e1382..fd07e2df66b8 100644
--- a/homeassistant/components/http/web_runner.py
+++ b/homeassistant/components/http/web_runner.py
@@ -3,74 +3,9 @@
import asyncio
from pathlib import Path
import socket
-from ssl import SSLContext
from typing import override
from aiohttp import web
-from yarl import URL
-
-
-class HomeAssistantTCPSite(web.BaseSite):
- """HomeAssistant specific aiohttp Site.
-
- Vanilla TCPSite accepts only str as host. However, the underlying asyncio's
- create_server() implementation does take a list of strings to bind to multiple
- host IP's. To support multiple server_host entries (e.g. to enable dual-stack
- explicitly), we would like to pass an array of strings. Bring our own
- implementation inspired by TCPSite.
-
- Custom TCPSite can be dropped when https://github.com/aio-libs/aiohttp/pull/4894
- is merged.
- """
-
- __slots__ = ("_host", "_hosturl", "_port", "_reuse_address", "_reuse_port")
-
- def __init__(
- self,
- runner: web.BaseRunner,
- host: str | list[str] | None,
- port: int,
- *,
- ssl_context: SSLContext | None = None,
- backlog: int = 128,
- reuse_address: bool | None = None,
- reuse_port: bool | None = None,
- ) -> None:
- """Initialize HomeAssistantTCPSite."""
- super().__init__(
- runner,
- ssl_context=ssl_context,
- backlog=backlog,
- )
- self._host = host
- self._port = port
- self._reuse_address = reuse_address
- self._reuse_port = reuse_port
-
- @property
- @override
- def name(self) -> str:
- """Return server URL."""
- scheme = "https" if self._ssl_context else "http"
- host = self._host[0] if isinstance(self._host, list) else "0.0.0.0"
- return str(URL.build(scheme=scheme, host=host, port=self._port))
-
- @override
- async def start(self) -> None:
- """Start server."""
- await super().start()
- loop = asyncio.get_running_loop()
- server = self._runner.server
- assert server is not None
- self._server = await loop.create_server(
- server,
- self._host,
- self._port,
- ssl=self._ssl_context,
- backlog=self._backlog,
- reuse_address=self._reuse_address,
- reuse_port=self._reuse_port,
- )
class HomeAssistantUnixSite(web.BaseSite):
diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py
new file mode 100644
index 000000000000..9aff44f1f682
--- /dev/null
+++ b/homeassistant/components/http/websocket_api.py
@@ -0,0 +1,107 @@
+"""WebSocket API for the HTTP integration user config."""
+
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.components.homeassistant import (
+ DOMAIN as HASS_DOMAIN,
+ SERVICE_HOMEASSISTANT_RESTART,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+
+from .config import HTTP_STORAGE_SCHEMA, async_get_and_load_store
+from .const import ATTR_CONFIG
+
+
+@callback
+def async_register_websocket_commands(hass: HomeAssistant) -> None:
+ """Register the HTTP config websocket commands."""
+ websocket_api.async_register_command(hass, websocket_get_config)
+ websocket_api.async_register_command(hass, websocket_set_config)
+ websocket_api.async_register_command(hass, websocket_promote_config)
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required("type"): "http/config"})
+@websocket_api.async_response
+async def websocket_get_config(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Return the HTTP configuration.
+
+ ``stable`` is the confirmed-working config
+ ``pending`` is an unconfirmed config awaiting promotion, or ``None``.
+ ``revert_at`` is when an unconfirmed pending config auto-reverts to
+ stable, or ``None`` when no revert is scheduled.
+ """
+ store = await async_get_and_load_store(hass)
+ connection.send_result(
+ msg["id"],
+ {
+ "stable": store.stable,
+ "pending": store.pending,
+ "revert_at": store.revert_deadline,
+ },
+ )
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "http/config/configure",
+ vol.Required(ATTR_CONFIG): vol.Any(None, HTTP_STORAGE_SCHEMA),
+ }
+)
+@websocket_api.async_response
+async def websocket_set_config(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Store a new pending HTTP configuration and restart to apply it.
+
+ Restart whenever the pending slot changes, so the runtime config is
+ refreshed. The result reports whether a restart was triggered via
+ ``{"restart": bool}``.
+ """
+ store = await async_get_and_load_store(hass)
+ previous_pending = store.pending
+ await store.async_set_pending(msg[ATTR_CONFIG])
+ restart = store.pending != previous_pending
+ connection.send_result(msg["id"], {"restart": restart})
+
+ if restart:
+ await hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART)
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required("type"): "http/config/promote"})
+@websocket_api.async_response
+async def websocket_promote_config(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Promote the pending HTTP config to stable.
+
+ Called by the user after they have verified Home Assistant is
+ working correctly with the pending config. The stable config is
+ the one used by recovery mode, so promotion must be explicit.
+ """
+ store = await async_get_and_load_store(hass)
+ try:
+ await store.async_promote_pending()
+ except HomeAssistantError as err:
+ connection.send_error(
+ msg["id"],
+ websocket_api.const.ERR_NOT_ALLOWED,
+ str(err),
+ )
+ return
+
+ connection.send_result(msg["id"])
diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py
index ac9e37270a72..55ddc6eb3044 100644
--- a/homeassistant/components/humidifier/condition.py
+++ b/homeassistant/components/humidifier/condition.py
@@ -4,14 +4,7 @@ from typing import TYPE_CHECKING, override
import voluptuous as vol
-from homeassistant.const import (
- ATTR_MODE,
- CONF_MODE,
- CONF_OPTIONS,
- PERCENTAGE,
- STATE_OFF,
- STATE_ON,
-)
+from homeassistant.const import CONF_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -27,11 +20,10 @@ from homeassistant.helpers.condition import (
from homeassistant.helpers.entity import get_supported_features
from .const import (
- ATTR_ACTION,
- ATTR_HUMIDITY,
DOMAIN,
HumidifierAction,
HumidifierEntityFeature,
+ HumidifierEntityStateAttribute,
)
IS_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
@@ -54,7 +46,9 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for humidifier target humidity."""
- _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
+ _domain_specs = {
+ DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.HUMIDITY)
+ }
_valid_unit = PERCENTAGE
@override
@@ -62,14 +56,17 @@ class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Skip humidifier entities that do not expose a target humidity."""
return (
super()._should_include(state)
- and state.attributes.get(ATTR_HUMIDITY) is not None
+ and state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY)
+ is not None
)
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
- _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)}
+ _domain_specs = {
+ DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.MODE)
+ }
_schema = IS_MODE_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
@@ -94,10 +91,12 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
"is_drying": make_entity_state_condition(
- {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
+ {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)},
+ HumidifierAction.DRYING,
),
"is_humidifying": make_entity_state_condition(
- {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
+ {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)},
+ HumidifierAction.HUMIDIFYING,
),
"is_mode": IsModeCondition,
"is_target_humidity": IsTargetHumidityCondition,
diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py
index d3ae95cfc5d0..3e2124e129a3 100644
--- a/homeassistant/components/humidifier/device_action.py
+++ b/homeassistant/components/humidifier/device_action.py
@@ -122,7 +122,12 @@ async def async_get_action_capabilities(
hass, config[CONF_ENTITY_ID]
)
available_modes = (
- get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or []
+ get_capability(
+ hass,
+ entry.entity_id,
+ const.HumidifierEntityCapabilityAttribute.AVAILABLE_MODES,
+ )
+ or []
)
except HomeAssistantError:
available_modes = []
diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py
index f3ac0c116058..193f3f54d3ca 100644
--- a/homeassistant/components/humidifier/device_condition.py
+++ b/homeassistant/components/humidifier/device_condition.py
@@ -42,6 +42,11 @@ MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend(
CONDITION_SCHEMA = vol.Any(TOGGLE_CONDITION, MODE_CONDITION)
+# Maps a state attribute to the condition config key used to compare against it.
+_STATE_ATTRIBUTE_TO_CONFIG_KEY = {
+ const.HumidifierEntityStateAttribute.MODE: ATTR_MODE,
+}
+
async def async_get_conditions(
hass: HomeAssistant, device_id: str
@@ -77,7 +82,7 @@ def async_condition_from_config(
) -> condition.ConditionCheckerType:
"""Create a function to test a device condition."""
if config[CONF_TYPE] == "is_mode":
- attribute = ATTR_MODE
+ attribute = const.HumidifierEntityStateAttribute.MODE
else:
return toggle_entity.async_condition_from_config(hass, config)
@@ -89,7 +94,8 @@ def async_condition_from_config(
return (
entity_id is not None
and (state := hass.states.get(entity_id)) is not None
- and state.attributes.get(attribute) == config[attribute]
+ and state.attributes.get(attribute)
+ == config[_STATE_ATTRIBUTE_TO_CONFIG_KEY[attribute]]
)
return test_is_state
@@ -109,7 +115,12 @@ async def async_get_condition_capabilities(
hass, config[CONF_ENTITY_ID]
)
modes = (
- get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or []
+ get_capability(
+ hass,
+ entry.entity_id,
+ const.HumidifierEntityCapabilityAttribute.AVAILABLE_MODES,
+ )
+ or []
)
except HomeAssistantError:
modes = []
diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py
index 7fe5adac40fb..bfd2fe00844a 100644
--- a/homeassistant/components/humidifier/device_trigger.py
+++ b/homeassistant/components/humidifier/device_trigger.py
@@ -25,7 +25,8 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
-from . import ATTR_CURRENT_HUMIDITY, DOMAIN
+from . import DOMAIN
+from .const import HumidifierEntityStateAttribute
# mypy: disallow-any-generics
@@ -94,7 +95,10 @@ async def async_get_triggers(
}
)
- if state and ATTR_CURRENT_HUMIDITY in state.attributes:
+ if (
+ state
+ and HumidifierEntityStateAttribute.CURRENT_HUMIDITY in state.attributes
+ ):
triggers.append(
{
**base_trigger,
diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py
index 48bafdb1113c..70ed62260155 100644
--- a/homeassistant/components/humidifier/intent.py
+++ b/homeassistant/components/humidifier/intent.py
@@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, intent
from . import (
- ATTR_AVAILABLE_MODES,
ATTR_HUMIDITY,
DOMAIN,
SERVICE_SET_HUMIDITY,
@@ -17,6 +16,7 @@ from . import (
SERVICE_TURN_ON,
HumidifierEntityFeature,
)
+from .const import HumidifierEntityCapabilityAttribute
INTENT_HUMIDITY = "HassHumidifierSetpoint"
INTENT_MODE = "HassHumidifierMode"
@@ -117,7 +117,10 @@ class SetModeHandler(intent.IntentHandler):
intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes")
mode = slots["mode"]["value"]
- if mode not in (state.attributes.get(ATTR_AVAILABLE_MODES) or []):
+ if mode not in (
+ state.attributes.get(HumidifierEntityCapabilityAttribute.AVAILABLE_MODES)
+ or []
+ ):
raise intent.IntentHandleError(
f"Entity {state.name} does not support {mode} mode"
)
diff --git a/homeassistant/components/humidifier/llm.py b/homeassistant/components/humidifier/llm.py
new file mode 100644
index 000000000000..d799345b7e76
--- /dev/null
+++ b/homeassistant/components/humidifier/llm.py
@@ -0,0 +1,35 @@
+"""LLM tools for the humidifier integration."""
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from .const import DOMAIN
+from .intent import INTENT_HUMIDITY, INTENT_MODE
+
+# Intents owned by this integration that are exposed as LLM tools.
+LLM_INTENTS = (INTENT_MODE, INTENT_HUMIDITY)
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return LLM tools for the integration's intents when its domain is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ if not any(
+ async_should_expose(hass, llm_context.assistant, state.entity_id)
+ for state in hass.states.async_all(DOMAIN)
+ ):
+ return None
+
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type in LLM_INTENTS
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/humidifier/reproduce_state.py b/homeassistant/components/humidifier/reproduce_state.py
index 45941f327374..f4eb37d33a1b 100644
--- a/homeassistant/components/humidifier/reproduce_state.py
+++ b/homeassistant/components/humidifier/reproduce_state.py
@@ -14,10 +14,22 @@ from homeassistant.const import (
)
from homeassistant.core import Context, HomeAssistant, State
-from .const import ATTR_HUMIDITY, DOMAIN, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE
+from .const import (
+ ATTR_HUMIDITY,
+ DOMAIN,
+ SERVICE_SET_HUMIDITY,
+ SERVICE_SET_MODE,
+ HumidifierEntityStateAttribute,
+)
_LOGGER = logging.getLogger(__name__)
+# Maps a state attribute to the service call argument used to restore it.
+_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[HumidifierEntityStateAttribute, str] = {
+ HumidifierEntityStateAttribute.MODE: ATTR_MODE,
+ HumidifierEntityStateAttribute.HUMIDITY: ATTR_HUMIDITY,
+}
+
async def _async_reproduce_states(
hass: HomeAssistant,
@@ -31,12 +43,16 @@ async def _async_reproduce_states(
_LOGGER.warning("Unable to find entity %s", state.entity_id)
return
- async def call_service(service: str, keys: Iterable[str]) -> None:
- """Call service with set of attributes given."""
+ async def call_service(
+ service: str, attributes: Iterable[HumidifierEntityStateAttribute]
+ ) -> None:
+ """Call service with the given state attributes."""
data = {"entity_id": state.entity_id}
- for key in keys:
- if key in state.attributes:
- data[key] = state.attributes[key]
+ for attribute in attributes:
+ if attribute in state.attributes:
+ data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = state.attributes[
+ attribute
+ ]
await hass.services.async_call(
DOMAIN, service, data, blocking=True, context=context
@@ -66,16 +82,18 @@ async def _async_reproduce_states(
# Then set the mode before target humidity, because switching modes
# may invalidate target humidity
- if ATTR_MODE in state.attributes and state.attributes[
- ATTR_MODE
- ] != cur_state.attributes.get(ATTR_MODE):
- await call_service(SERVICE_SET_MODE, [ATTR_MODE])
+ if HumidifierEntityStateAttribute.MODE in state.attributes and state.attributes[
+ HumidifierEntityStateAttribute.MODE
+ ] != cur_state.attributes.get(HumidifierEntityStateAttribute.MODE):
+ await call_service(SERVICE_SET_MODE, [HumidifierEntityStateAttribute.MODE])
# Next, restore target humidity for the current mode
- if ATTR_HUMIDITY in state.attributes and state.attributes[
- ATTR_HUMIDITY
- ] != cur_state.attributes.get(ATTR_HUMIDITY):
- await call_service(SERVICE_SET_HUMIDITY, [ATTR_HUMIDITY])
+ if HumidifierEntityStateAttribute.HUMIDITY in state.attributes and state.attributes[
+ HumidifierEntityStateAttribute.HUMIDITY
+ ] != cur_state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY):
+ await call_service(
+ SERVICE_SET_HUMIDITY, [HumidifierEntityStateAttribute.HUMIDITY]
+ )
async def async_reproduce_states(
diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py
index c8d588512a04..ff61fc6962e8 100644
--- a/homeassistant/components/humidifier/significant_change.py
+++ b/homeassistant/components/humidifier/significant_change.py
@@ -8,13 +8,13 @@ from homeassistant.helpers.significant_change import (
check_valid_float,
)
-from . import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE
+from .const import HumidifierEntityStateAttribute
SIGNIFICANT_ATTRIBUTES: set[str] = {
- ATTR_ACTION,
- ATTR_CURRENT_HUMIDITY,
- ATTR_HUMIDITY,
- ATTR_MODE,
+ HumidifierEntityStateAttribute.ACTION,
+ HumidifierEntityStateAttribute.CURRENT_HUMIDITY,
+ HumidifierEntityStateAttribute.HUMIDITY,
+ HumidifierEntityStateAttribute.MODE,
}
@@ -40,7 +40,10 @@ def async_check_significant_change(
changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s}
for attr_name in changed_attrs:
- if attr_name in [ATTR_ACTION, ATTR_MODE]:
+ if attr_name in [
+ HumidifierEntityStateAttribute.ACTION,
+ HumidifierEntityStateAttribute.MODE,
+ ]:
return True
old_attr_value = old_attrs.get(attr_name)
diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py
index 23c4e5476b25..3b01529de6b5 100644
--- a/homeassistant/components/humidifier/trigger.py
+++ b/homeassistant/components/humidifier/trigger.py
@@ -4,7 +4,7 @@ from typing import override
import voluptuous as vol
-from homeassistant.const import ATTR_MODE, CONF_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON
+from homeassistant.const import CONF_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -18,7 +18,12 @@ from homeassistant.helpers.trigger import (
make_entity_target_state_trigger,
)
-from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature
+from .const import (
+ DOMAIN,
+ HumidifierAction,
+ HumidifierEntityFeature,
+ HumidifierEntityStateAttribute,
+)
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
@@ -40,7 +45,9 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
class ModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for humidifier mode changes."""
- _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)}
+ _domain_specs = {
+ DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.MODE)
+ }
_schema = MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
@@ -62,10 +69,12 @@ class ModeChangedTrigger(EntityTargetStateTriggerBase):
TRIGGERS: dict[str, type[Trigger]] = {
"mode_changed": ModeChangedTrigger,
"started_drying": make_entity_target_state_trigger(
- {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
+ {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)},
+ HumidifierAction.DRYING,
),
"started_humidifying": make_entity_target_state_trigger(
- {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
+ {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)},
+ HumidifierAction.HUMIDIFYING,
),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py
index 35c955fa5c95..0cd4816f379d 100644
--- a/homeassistant/components/hunterdouglas_powerview/cover.py
+++ b/homeassistant/components/hunterdouglas_powerview/cover.py
@@ -139,7 +139,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
@override
def available(self) -> bool:
"""Return True if shade position data is available."""
- return super().available and self.positions.primary is not None
+ return super().available and self._is_position_available
+
+ @property
+ def _is_position_available(self) -> bool:
+ """Return if the cover contains positional data."""
+ return self.positions.primary is not None
@property
@override
@@ -567,9 +572,9 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase):
@property
@override
- def available(self) -> bool:
- """Return True if shade position data is available."""
- return super().available and self.positions.tilt is not None
+ def _is_position_available(self) -> bool:
+ """Return if the cover contains positional data."""
+ return self.positions.tilt is not None
class PowerViewShadeTopDown(PowerViewShadeBase):
diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py
index eb90737faba3..89a04a4b143d 100644
--- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py
+++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py
@@ -3,9 +3,11 @@
from dataclasses import asdict
from typing import Any
-import attr
-
-from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
+from homeassistant.components.diagnostics import (
+ async_redact_data,
+ device_entry_as_dict,
+ entity_entry_as_dict,
+)
from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -75,7 +77,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str,
# Gather information how this device is represented in Home Assistant
entity_registry = er.async_get(hass)
- data = async_redact_data(attr.asdict(device), REDACT_CONFIG)
+ data = async_redact_data(device_entry_as_dict(device), REDACT_CONFIG)
data["entities"] = []
entities: list[dict[str, Any]] = data["entities"]
diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py
index c6f19c79e252..670ad3bcb54d 100644
--- a/homeassistant/components/hydrawise/coordinator.py
+++ b/homeassistant/components/hydrawise/coordinator.py
@@ -142,17 +142,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
if removed_zones := previous_zones - current_zones:
LOGGER.debug("Removed zones: %s", ", ".join(removed_zones))
for zone_id in removed_zones:
- device_registry.async_update_device(
- device_id=previous_zones_by_id[zone_id].id,
- remove_config_entry_id=self.config_entry.entry_id,
- )
+ device_registry.async_remove_device(previous_zones_by_id[zone_id].id)
if removed_controllers := previous_controllers - current_controllers:
LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers))
for controller_id in removed_controllers:
- device_registry.async_update_device(
- device_id=previous_controllers_by_id[controller_id].id,
- remove_config_entry_id=self.config_entry.entry_id,
+ device_registry.async_remove_device(
+ previous_controllers_by_id[controller_id].id
)
if new_controller_ids := current_controllers - previous_controllers:
diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py
index d98ec46d1daf..8c04d071d402 100644
--- a/homeassistant/components/icloud/account.py
+++ b/homeassistant/components/icloud/account.py
@@ -16,7 +16,7 @@ from pyicloud.services.findmyiphone import AppleDevice
from homeassistant.components.zone import async_active_zone
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_USERNAME
+from homeassistant.const import CONF_USERNAME, EntityStateAttribute
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import dispatcher_send
@@ -256,8 +256,8 @@ class IcloudAccount:
for zone_state in zones:
if zone_state is None:
continue
- zone_state_lat = zone_state.attributes[DEVICE_LOCATION_LATITUDE]
- zone_state_long = zone_state.attributes[DEVICE_LOCATION_LONGITUDE]
+ zone_state_lat = zone_state.attributes[EntityStateAttribute.LATITUDE]
+ zone_state_long = zone_state.attributes[EntityStateAttribute.LONGITUDE]
zone_distance = distance(
device.location[DEVICE_LOCATION_LATITUDE],
device.location[DEVICE_LOCATION_LONGITUDE],
diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json
index 8379e224a0a0..ad8824f23300 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==12.2.0"]
+ "requirements": ["Pillow==12.3.0"]
}
diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json
index 2ff27cbb0259..8cc18fbd7dd0 100644
--- a/homeassistant/components/imgw_pib/manifest.json
+++ b/homeassistant/components/imgw_pib/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
- "requirements": ["imgw_pib==2.4.0"]
+ "requirements": ["imgw_pib==2.4.3"]
}
diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json
index d0974f3e3bb2..1f438e8e3d12 100644
--- a/homeassistant/components/immich/manifest.json
+++ b/homeassistant/components/immich/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "platinum",
- "requirements": ["aioimmich==0.15.1"]
+ "requirements": ["aioimmich==0.16.1"]
}
diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py
index 972dee03f3c3..dd7242ae2bc7 100644
--- a/homeassistant/components/imou/button.py
+++ b/homeassistant/components/imou/button.py
@@ -5,7 +5,11 @@ from typing import override
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice
-from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,15 +27,6 @@ PARAM_PTZ_DOWN = "ptz_down"
PARAM_PTZ_LEFT = "ptz_left"
PARAM_PTZ_RIGHT = "ptz_right"
-BUTTON_TYPES = (
- PARAM_RESTART_DEVICE,
- PARAM_MUTE,
- PARAM_PTZ_UP,
- PARAM_PTZ_DOWN,
- PARAM_PTZ_LEFT,
- PARAM_PTZ_RIGHT,
-)
-
PTZ_BUTTON_TYPES = (
PARAM_PTZ_UP,
PARAM_PTZ_DOWN,
@@ -39,20 +34,43 @@ PTZ_BUTTON_TYPES = (
PARAM_PTZ_RIGHT,
)
-BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = {
- PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART,
-}
+BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = (
+ ButtonEntityDescription(
+ key=PARAM_RESTART_DEVICE,
+ device_class=ButtonDeviceClass.RESTART,
+ ),
+ ButtonEntityDescription(
+ key=PARAM_MUTE,
+ translation_key=PARAM_MUTE,
+ ),
+ ButtonEntityDescription(
+ key=PARAM_PTZ_UP,
+ translation_key=PARAM_PTZ_UP,
+ ),
+ ButtonEntityDescription(
+ key=PARAM_PTZ_DOWN,
+ translation_key=PARAM_PTZ_DOWN,
+ ),
+ ButtonEntityDescription(
+ key=PARAM_PTZ_LEFT,
+ translation_key=PARAM_PTZ_LEFT,
+ ),
+ ButtonEntityDescription(
+ key=PARAM_PTZ_RIGHT,
+ translation_key=PARAM_PTZ_RIGHT,
+ ),
+)
def _iter_buttons(
coordinator: ImouDataUpdateCoordinator,
-) -> list[tuple[str, ImouHaDevice]]:
- """Return (button_type, device) pairs for supported buttons."""
+) -> list[tuple[ButtonEntityDescription, ImouHaDevice]]:
+ """Return (description, device) pairs for supported buttons."""
return [
- (button_type, device)
+ (description, device)
for device in coordinator.devices
- for button_type in device.buttons
- if button_type in BUTTON_TYPES
+ for description in BUTTON_TYPES
+ if description.key in device.buttons
]
@@ -67,8 +85,8 @@ async def async_setup_entry(
def _add_buttons(new_devices: list[ImouHaDevice]) -> None:
device_keys = {imou_device_identifier(device) for device in new_devices}
async_add_entities(
- ImouButton(coordinator, button_type, device)
- for button_type, device in _iter_buttons(coordinator)
+ ImouButton(coordinator, description, device)
+ for description, device in _iter_buttons(coordinator)
if imou_device_identifier(device) in device_keys
)
@@ -86,17 +104,7 @@ async def async_setup_entry(
class ImouButton(ImouEntity, ButtonEntity):
"""Imou button entity."""
- def __init__(
- self,
- coordinator: ImouDataUpdateCoordinator,
- entity_type: str,
- device: ImouHaDevice,
- ) -> None:
- """Initialize the Imou button entity."""
- super().__init__(coordinator, entity_type, device)
- if device_class := BUTTON_DEVICE_CLASS.get(entity_type):
- self._attr_device_class = device_class
- self._attr_translation_key = None
+ entity_description: ButtonEntityDescription
@override
async def async_press(self) -> None:
diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py
index a06a413b80b0..79acdedc9c44 100644
--- a/homeassistant/components/imou/camera.py
+++ b/homeassistant/components/imou/camera.py
@@ -1,12 +1,17 @@
"""Support for Imou camera entities."""
+from dataclasses import dataclass
from typing import override
from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice
-from homeassistant.components.camera import Camera, CameraEntityFeature
+from homeassistant.components.camera import (
+ Camera,
+ CameraEntityDescription,
+ CameraEntityFeature,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,9 +28,25 @@ CAMERA_STREAM_RESOLUTION_SD = "SD"
PYIMOUAPI_LIVE_PROTOCOL = "https"
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3
-CAMERA_TYPES = (
- ("camera_sd", CAMERA_STREAM_RESOLUTION_SD),
- ("camera_hd", PARAM_HD),
+
+@dataclass(frozen=True, kw_only=True)
+class ImouCameraEntityDescription(CameraEntityDescription):
+ """Describes an Imou camera entity."""
+
+ resolution: str
+
+
+CAMERA_TYPES: tuple[ImouCameraEntityDescription, ...] = (
+ ImouCameraEntityDescription(
+ key="camera_sd",
+ translation_key="camera_sd",
+ resolution=CAMERA_STREAM_RESOLUTION_SD,
+ ),
+ ImouCameraEntityDescription(
+ key="camera_hd",
+ translation_key="camera_hd",
+ resolution=PARAM_HD,
+ ),
)
@@ -40,11 +61,11 @@ async def async_setup_entry(
def _add_cameras(new_devices: list[ImouHaDevice]) -> None:
device_keys = {imou_device_identifier(device) for device in new_devices}
async_add_entities(
- ImouCamera(coordinator, entity_type, device, resolution)
+ ImouCamera(coordinator, description, device)
for device in coordinator.devices
if device.channel_id is not None
if imou_device_identifier(device) in device_keys
- for entity_type, resolution in CAMERA_TYPES
+ for description in CAMERA_TYPES
)
coordinator.new_device_callbacks.append(_add_cameras)
@@ -61,19 +82,18 @@ async def async_setup_entry(
class ImouCamera(ImouEntity, Camera):
"""Representation of an Imou camera stream."""
+ entity_description: ImouCameraEntityDescription
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
- entity_type: str,
+ description: ImouCameraEntityDescription,
device: ImouHaDevice,
- resolution: str,
) -> None:
"""Initialize the camera entity."""
- self._resolution = resolution
Camera.__init__(self)
- super().__init__(coordinator, entity_type, device)
+ super().__init__(coordinator, description, device)
@override
async def stream_source(self) -> str | None:
@@ -81,7 +101,7 @@ class ImouCamera(ImouEntity, Camera):
try:
return await self.coordinator.device_manager.async_get_device_stream(
self.device,
- self._resolution,
+ self.entity_description.resolution,
PYIMOUAPI_LIVE_PROTOCOL,
)
except ImouException as err:
diff --git a/homeassistant/components/imou/entity.py b/homeassistant/components/imou/entity.py
index ea21763eb946..e9c25f64dbeb 100644
--- a/homeassistant/components/imou/entity.py
+++ b/homeassistant/components/imou/entity.py
@@ -5,6 +5,7 @@ from typing import override
from pyimouapi.ha_device import DeviceStatus, ImouHaDevice
from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier
@@ -19,15 +20,15 @@ class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]):
def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
- entity_type: str,
+ description: EntityDescription,
device: ImouHaDevice,
) -> None:
"""Initialize the Imou entity."""
super().__init__(coordinator)
- self._entity_type = entity_type
+ self.entity_description = description
+ self._entity_type = description.key
self._device_key = imou_device_identifier(device)
- self._attr_unique_id = f"{self._device_key}${entity_type}"
- self._attr_translation_key = entity_type
+ self._attr_unique_id = f"{self._device_key}${description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_key)},
name=device.channel_name or device.device_name,
diff --git a/homeassistant/components/imou/manifest.json b/homeassistant/components/imou/manifest.json
index 7d25d81e79ed..0ef2893fc15a 100644
--- a/homeassistant/components/imou/manifest.json
+++ b/homeassistant/components/imou/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
- "requirements": ["pyimouapi==1.2.8"]
+ "requirements": ["pyimouapi==1.3.0"]
}
diff --git a/homeassistant/components/imou/switch.py b/homeassistant/components/imou/switch.py
index caed3462950b..be6b7764127d 100644
--- a/homeassistant/components/imou/switch.py
+++ b/homeassistant/components/imou/switch.py
@@ -5,7 +5,11 @@ from typing import Any, override
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice
-from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
+from homeassistant.components.switch import (
+ SwitchDeviceClass,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,32 +31,53 @@ from .entity import ImouEntity
PARALLEL_UPDATES = 0
-SWITCH_TYPES = (
- PARAM_AB_ALARM_SOUND,
- PARAM_AUDIO_ENCODE_CONTROL,
- PARAM_CLOSE_CAMERA,
- PARAM_HEADER_DETECT,
- PARAM_LIGHT,
- PARAM_MOTION_DETECT,
- PARAM_PLUG_SWITCH,
- PARAM_WHITE_LIGHT,
+SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
+ SwitchEntityDescription(
+ key=PARAM_AB_ALARM_SOUND,
+ translation_key=PARAM_AB_ALARM_SOUND,
+ ),
+ SwitchEntityDescription(
+ key=PARAM_AUDIO_ENCODE_CONTROL,
+ translation_key=PARAM_AUDIO_ENCODE_CONTROL,
+ ),
+ SwitchEntityDescription(
+ key=PARAM_CLOSE_CAMERA,
+ translation_key=PARAM_CLOSE_CAMERA,
+ ),
+ SwitchEntityDescription(
+ key=PARAM_HEADER_DETECT,
+ translation_key=PARAM_HEADER_DETECT,
+ ),
+ SwitchEntityDescription(
+ key=PARAM_LIGHT,
+ translation_key=PARAM_LIGHT,
+ device_class=SwitchDeviceClass.SWITCH,
+ ),
+ SwitchEntityDescription(
+ key=PARAM_MOTION_DETECT,
+ translation_key=PARAM_MOTION_DETECT,
+ ),
+ SwitchEntityDescription(
+ key=PARAM_PLUG_SWITCH,
+ translation_key=PARAM_PLUG_SWITCH,
+ device_class=SwitchDeviceClass.SWITCH,
+ ),
+ SwitchEntityDescription(
+ key=PARAM_WHITE_LIGHT,
+ translation_key=PARAM_WHITE_LIGHT,
+ ),
)
-SWITCH_DEVICE_CLASS: dict[str, SwitchDeviceClass] = {
- PARAM_LIGHT: SwitchDeviceClass.SWITCH,
- PARAM_PLUG_SWITCH: SwitchDeviceClass.SWITCH,
-}
-
def _iter_switches(
coordinator: ImouDataUpdateCoordinator,
-) -> list[tuple[str, ImouHaDevice]]:
- """Return (switch_type, device) pairs for supported switches."""
+) -> list[tuple[SwitchEntityDescription, ImouHaDevice]]:
+ """Return (description, device) pairs for supported switches."""
return [
- (switch_type, device)
+ (description, device)
for device in coordinator.devices
- for switch_type in device.switches
- if switch_type in SWITCH_TYPES
+ for description in SWITCH_TYPES
+ if description.key in device.switches
]
@@ -67,8 +92,8 @@ async def async_setup_entry(
def _add_switches(new_devices: list[ImouHaDevice]) -> None:
device_keys = {imou_device_identifier(device) for device in new_devices}
async_add_entities(
- ImouSwitch(coordinator, switch_type, device)
- for switch_type, device in _iter_switches(coordinator)
+ ImouSwitch(coordinator, description, device)
+ for description, device in _iter_switches(coordinator)
if imou_device_identifier(device) in device_keys
)
@@ -86,15 +111,7 @@ async def async_setup_entry(
class ImouSwitch(ImouEntity, SwitchEntity):
"""Imou switch entity."""
- def __init__(
- self,
- coordinator: ImouDataUpdateCoordinator,
- entity_type: str,
- device: ImouHaDevice,
- ) -> None:
- """Initialize the Imou switch entity."""
- super().__init__(coordinator, entity_type, device)
- self._attr_device_class = SWITCH_DEVICE_CLASS.get(entity_type)
+ entity_description: SwitchEntityDescription
@property
@override
diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py
index 09f3b795cbd9..fb11066fbb09 100644
--- a/homeassistant/components/influxdb/__init__.py
+++ b/homeassistant/components/influxdb/__init__.py
@@ -39,6 +39,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.data_entry_flow import FlowResultType
@@ -252,7 +253,9 @@ def _generate_event_to_json(conf: dict) -> Callable[[Event], dict[str, Any] | No
if measurement_attr == "entity_id":
measurement = state.entity_id
elif measurement_attr == "domain__device_class":
- device_class = state.attributes.get("device_class")
+ device_class = state.attributes.get(
+ EntityStateAttribute.DEVICE_CLASS
+ )
if device_class is None:
# This entity doesn't have a device_class set, use only domain
measurement = state.domain
diff --git a/homeassistant/components/infrared/entity.py b/homeassistant/components/infrared/entity.py
index 473c3a0d2a32..5fa7c085c9bd 100644
--- a/homeassistant/components/infrared/entity.py
+++ b/homeassistant/components/infrared/entity.py
@@ -49,6 +49,14 @@ class InfraredEmitterEntity(RestoreEntity):
__last_command_sent: str | None = None
+ @override
+ def _default_to_device_class_name(self) -> bool:
+ """Return True if an unnamed entity should be named by its device class.
+
+ For infrared emitters this is True if the entity has a device class.
+ """
+ return self.device_class is not None
+
@property
@final
@override
@@ -101,6 +109,14 @@ class InfraredReceiverEntity(RestoreEntity):
__last_signal_received: str | None = None
+ @override
+ def _default_to_device_class_name(self) -> bool:
+ """Return True if an unnamed entity should be named by its device class.
+
+ For infrared receivers this is True if the entity has a device class.
+ """
+ return self.device_class is not None
+
@cached_property
def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]:
"""Subscriber callback set, lazily initialized on first access."""
diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json
index c8d05986266a..8958e81d9eba 100644
--- a/homeassistant/components/infrared/manifest.json
+++ b/homeassistant/components/infrared/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
- "requirements": ["infrared-protocols==6.3.1"]
+ "requirements": ["infrared-protocols==7.5.0"]
}
diff --git a/homeassistant/components/infrared/strings.json b/homeassistant/components/infrared/strings.json
index 09d705d53cc0..3a21a72eac63 100644
--- a/homeassistant/components/infrared/strings.json
+++ b/homeassistant/components/infrared/strings.json
@@ -1,6 +1,6 @@
{
"entity_component": {
- "_": {
+ "emitter": {
"name": "Infrared emitter"
},
"receiver": {
diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py
index 74754be4ed06..68e18e3d1d0f 100644
--- a/homeassistant/components/input_boolean/__init__.py
+++ b/homeassistant/components/input_boolean/__init__.py
@@ -5,7 +5,7 @@ from typing import Any, Self, override
import voluptuous as vol
-from homeassistant.const import (
+from homeassistant.const import ( # noqa: F401
ATTR_EDITABLE,
CONF_ICON,
CONF_ID,
@@ -25,6 +25,8 @@ import homeassistant.helpers.service
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
+from .const import InputBooleanEntityStateAttribute
+
DOMAIN = "input_boolean"
_LOGGER = logging.getLogger(__name__)
@@ -146,7 +148,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity):
"""Representation of a boolean input."""
- _unrecorded_attributes = frozenset({ATTR_EDITABLE})
+ _unrecorded_attributes = frozenset({InputBooleanEntityStateAttribute.EDITABLE})
_attr_should_poll = False
editable: bool
@@ -190,7 +192,7 @@ class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity):
@override
def extra_state_attributes(self) -> dict[str, bool]:
"""Return the state attributes of the entity."""
- return {ATTR_EDITABLE: self.editable}
+ return {InputBooleanEntityStateAttribute.EDITABLE: self.editable}
@override
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/input_boolean/const.py b/homeassistant/components/input_boolean/const.py
new file mode 100644
index 000000000000..c2be02dff532
--- /dev/null
+++ b/homeassistant/components/input_boolean/const.py
@@ -0,0 +1,9 @@
+"""Constants for the input_boolean integration."""
+
+from enum import StrEnum
+
+
+class InputBooleanEntityStateAttribute(StrEnum):
+ """State attributes for input boolean entities."""
+
+ EDITABLE = "editable"
diff --git a/homeassistant/components/input_datetime/reproduce_state.py b/homeassistant/components/input_datetime/reproduce_state.py
index ea087f7d8369..f01ab5ed3cc4 100644
--- a/homeassistant/components/input_datetime/reproduce_state.py
+++ b/homeassistant/components/input_datetime/reproduce_state.py
@@ -9,7 +9,8 @@ from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.util import dt as dt_util
-from . import ATTR_DATE, ATTR_DATETIME, ATTR_TIME, CONF_HAS_DATE, CONF_HAS_TIME, DOMAIN
+from . import ATTR_DATE, ATTR_DATETIME, ATTR_TIME, DOMAIN
+from .const import InputDatetimeEntityCapabilityAttribute
_LOGGER = logging.getLogger(__name__)
@@ -44,8 +45,8 @@ async def _async_reproduce_state(
_LOGGER.warning("Unable to find entity %s", state.entity_id)
return
- has_time = cur_state.attributes.get(CONF_HAS_TIME)
- has_date = cur_state.attributes.get(CONF_HAS_DATE)
+ has_time = cur_state.attributes.get(InputDatetimeEntityCapabilityAttribute.HAS_TIME)
+ has_date = cur_state.attributes.get(InputDatetimeEntityCapabilityAttribute.HAS_DATE)
if not (
(is_valid_datetime(state.state) and has_date and has_time)
diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py
index 92bd543122e6..8558f203d12a 100644
--- a/homeassistant/components/input_number/__init__.py
+++ b/homeassistant/components/input_number/__init__.py
@@ -6,8 +6,8 @@ from typing import Any, Self, override
import voluptuous as vol
-from homeassistant.const import (
- ATTR_EDITABLE,
+from homeassistant.components.number import NumberEntity
+from homeassistant.const import ( # noqa: F401
ATTR_MODE,
CONF_ICON,
CONF_ID,
@@ -24,6 +24,8 @@ import homeassistant.helpers.service
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
+from .const import InputNumberEntityStateAttribute
+
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_number"
@@ -149,7 +151,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_SET_VALUE,
{vol.Required(ATTR_VALUE): vol.Coerce(float)},
- "async_set_value",
+ "async_set_native_value",
)
component.async_register_entity_service(SERVICE_INCREMENT, None, "async_increment")
@@ -199,20 +201,31 @@ class NumberStorageCollection(collection.DictStorageCollection):
return {CONF_ID: item[CONF_ID]} | update_data
-class InputNumber(collection.CollectionEntity, RestoreEntity):
+# pylint: disable-next=home-assistant-enforce-class-module
+class InputNumber(collection.CollectionEntity, NumberEntity, RestoreEntity):
"""Representation of a slider."""
- _unrecorded_attributes = frozenset(
- {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP}
- )
+ _unrecorded_attributes = frozenset({InputNumberEntityStateAttribute.EDITABLE})
_attr_should_poll = False
editable: bool
def __init__(self, config: ConfigType) -> None:
"""Initialize an input number."""
- self._config = config
- self._current_value: float | None = config.get(CONF_INITIAL)
+ self._initial_value: float | None = config.get(CONF_INITIAL)
+ self._attr_native_value = self._initial_value
+ self._update_config_attributes(config)
+
+ def _update_config_attributes(self, config: ConfigType) -> None:
+ """Update attributes based on the config."""
+ self._attr_icon = config.get(CONF_ICON)
+ self._attr_mode = config[CONF_MODE]
+ self._attr_name = config.get(CONF_NAME)
+ self._attr_native_min_value = config[CONF_MIN]
+ self._attr_native_max_value = config[CONF_MAX]
+ self._attr_native_step = config[CONF_STEP]
+ self._attr_unique_id = config[CONF_ID]
+ self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
@classmethod
@override
@@ -231,69 +244,20 @@ class InputNumber(collection.CollectionEntity, RestoreEntity):
input_num.editable = False
return input_num
- @property
- def _minimum(self) -> float:
- """Return minimum allowed value."""
- return self._config[CONF_MIN]
-
- @property
- def _maximum(self) -> float:
- """Return maximum allowed value."""
- return self._config[CONF_MAX]
-
- @property
- @override
- def name(self):
- """Return the name of the input slider."""
- return self._config.get(CONF_NAME)
-
- @property
- @override
- def icon(self) -> str | None:
- """Return the icon to be used for this entity."""
- return self._config.get(CONF_ICON)
-
- @property
- @override
- def state(self):
- """Return the state of the component."""
- return self._current_value
-
- @property
- def _step(self) -> int:
- """Return entity's increment/decrement step."""
- return self._config[CONF_STEP]
-
- @property
- @override
- def unit_of_measurement(self):
- """Return the unit the value is expressed in."""
- return self._config.get(CONF_UNIT_OF_MEASUREMENT)
-
- @property
- @override
- def unique_id(self) -> str | None:
- """Return unique id of the entity."""
- return self._config[CONF_ID]
-
@property
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return {
- ATTR_INITIAL: self._config.get(CONF_INITIAL),
- ATTR_EDITABLE: self.editable,
- ATTR_MIN: self._minimum,
- ATTR_MAX: self._maximum,
- ATTR_STEP: self._step,
- ATTR_MODE: self._config[CONF_MODE],
+ InputNumberEntityStateAttribute.INITIAL: self._initial_value,
+ InputNumberEntityStateAttribute.EDITABLE: self.editable,
}
@override
async def async_added_to_hass(self):
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
- if self._current_value is not None:
+ if self._attr_native_value is not None:
return
value: float | None = None
@@ -302,39 +266,47 @@ class InputNumber(collection.CollectionEntity, RestoreEntity):
value = float(state.state)
# Check against None because value can be 0
- if value is not None and self._minimum <= value <= self._maximum:
- self._current_value = value
+ if (
+ value is not None
+ and self.native_min_value <= value <= self.native_max_value
+ ):
+ self._attr_native_value = value
else:
- self._current_value = self._minimum
+ self._attr_native_value = self.native_min_value
- async def async_set_value(self, value):
+ @override
+ async def async_set_native_value(self, value):
"""Set new value."""
num_value = float(value)
- if num_value < self._minimum or num_value > self._maximum:
+ if num_value < self.native_min_value or num_value > self.native_max_value:
raise vol.Invalid(
- f"Invalid value for {self.entity_id}: {value} (range {self._minimum} -"
- f" {self._maximum})"
+ f"Invalid value for {self.entity_id}: {value} (range "
+ f"{self.native_min_value} - {self.native_max_value})"
)
- self._current_value = num_value
+ self._attr_native_value = num_value
self.async_write_ha_state()
async def async_increment(self):
"""Increment value."""
- await self.async_set_value(min(self._current_value + self._step, self._maximum))
+ await self.async_set_native_value(
+ min(self._attr_native_value + self.native_step, self.native_max_value)
+ )
async def async_decrement(self):
"""Decrement value."""
- await self.async_set_value(max(self._current_value - self._step, self._minimum))
+ await self.async_set_native_value(
+ max(self._attr_native_value - self.native_step, self.native_min_value)
+ )
@override
async def async_update_config(self, config: ConfigType) -> None:
"""Handle when the config is updated."""
- self._config = config
+ self._update_config_attributes(config)
# just in case min/max values changed
- if self._current_value is None:
+ if self._attr_native_value is None:
return
- self._current_value = min(self._current_value, self._maximum)
- self._current_value = max(self._current_value, self._minimum)
+ self._attr_native_value = min(self._attr_native_value, self.native_max_value)
+ self._attr_native_value = max(self._attr_native_value, self.native_min_value)
self.async_write_ha_state()
diff --git a/homeassistant/components/input_number/const.py b/homeassistant/components/input_number/const.py
new file mode 100644
index 000000000000..c052ef8afdaa
--- /dev/null
+++ b/homeassistant/components/input_number/const.py
@@ -0,0 +1,10 @@
+"""Constants for the input_number integration."""
+
+from enum import StrEnum
+
+
+class InputNumberEntityStateAttribute(StrEnum):
+ """State attributes for input number entities."""
+
+ INITIAL = "initial"
+ EDITABLE = "editable"
diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py
index 7d7c809c68e5..ac220c1a24b6 100644
--- a/homeassistant/components/input_select/__init__.py
+++ b/homeassistant/components/input_select/__init__.py
@@ -15,8 +15,9 @@ from homeassistant.components.select import (
SERVICE_SELECT_OPTION,
SERVICE_SELECT_PREVIOUS,
SelectEntity,
+ SelectEntityCapabilityAttribute,
)
-from homeassistant.const import (
+from homeassistant.const import ( # noqa: F401
ATTR_EDITABLE,
CONF_ICON,
CONF_ID,
@@ -33,6 +34,8 @@ import homeassistant.helpers.service
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
+from .const import InputSelectEntityStateAttribute
+
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_select"
@@ -250,9 +253,10 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity):
"""Representation of a select input."""
_entity_component_unrecorded_attributes = (
- SelectEntity._entity_component_unrecorded_attributes - {ATTR_OPTIONS} # noqa: SLF001
+ SelectEntity._entity_component_unrecorded_attributes # noqa: SLF001
+ - {SelectEntityCapabilityAttribute.OPTIONS}
)
- _unrecorded_attributes = frozenset({ATTR_EDITABLE})
+ _unrecorded_attributes = frozenset({InputSelectEntityStateAttribute.EDITABLE})
_attr_should_poll = False
editable: bool
@@ -299,7 +303,7 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity):
@override
def extra_state_attributes(self) -> dict[str, bool]:
"""Return the state attributes."""
- return {ATTR_EDITABLE: self.editable}
+ return {InputSelectEntityStateAttribute.EDITABLE: self.editable}
@override
async def async_select_option(self, option: str) -> None:
diff --git a/homeassistant/components/input_select/const.py b/homeassistant/components/input_select/const.py
new file mode 100644
index 000000000000..46e2e69d3371
--- /dev/null
+++ b/homeassistant/components/input_select/const.py
@@ -0,0 +1,9 @@
+"""Constants for the input_select integration."""
+
+from enum import StrEnum
+
+
+class InputSelectEntityStateAttribute(StrEnum):
+ """State attributes for input select entities."""
+
+ EDITABLE = "editable"
diff --git a/homeassistant/components/input_select/reproduce_state.py b/homeassistant/components/input_select/reproduce_state.py
index f3781d70cd4f..a02dab5ea15b 100644
--- a/homeassistant/components/input_select/reproduce_state.py
+++ b/homeassistant/components/input_select/reproduce_state.py
@@ -5,6 +5,7 @@ from collections.abc import Iterable, Mapping
import logging
from typing import Any
+from homeassistant.components.select import SelectEntityCapabilityAttribute
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION
from homeassistant.core import Context, HomeAssistant, State
@@ -39,9 +40,11 @@ async def _async_reproduce_state(
service_data = {ATTR_ENTITY_ID: state.entity_id}
# If options are specified, call SERVICE_SET_OPTIONS
- if ATTR_OPTIONS in state.attributes:
+ if SelectEntityCapabilityAttribute.OPTIONS in state.attributes:
service = SERVICE_SET_OPTIONS
- service_data[ATTR_OPTIONS] = state.attributes[ATTR_OPTIONS]
+ service_data[ATTR_OPTIONS] = state.attributes[
+ SelectEntityCapabilityAttribute.OPTIONS
+ ]
await hass.services.async_call(
DOMAIN, service, service_data, context=context, blocking=True
diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py
index 365e489a79a4..577f703c3a3a 100644
--- a/homeassistant/components/input_text/__init__.py
+++ b/homeassistant/components/input_text/__init__.py
@@ -5,8 +5,8 @@ from typing import Any, Self, override
import voluptuous as vol
-from homeassistant.const import (
- ATTR_EDITABLE,
+from homeassistant.components.text import TextEntity
+from homeassistant.const import ( # noqa: F401
ATTR_MODE,
CONF_ICON,
CONF_ID,
@@ -24,6 +24,8 @@ import homeassistant.helpers.service
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
+from .const import InputTextEntityStateAttribute
+
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_text"
@@ -187,21 +189,30 @@ class InputTextStorageCollection(collection.DictStorageCollection):
return {CONF_ID: item[CONF_ID]} | update_data
-class InputText(collection.CollectionEntity, RestoreEntity):
+# pylint: disable-next=home-assistant-enforce-class-module
+class InputText(collection.CollectionEntity, TextEntity, RestoreEntity):
"""Represent a text box."""
- _unrecorded_attributes = frozenset(
- {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN}
- )
+ _unrecorded_attributes = frozenset({InputTextEntityStateAttribute.EDITABLE})
_attr_should_poll = False
- _current_value: str | None
editable: bool
def __init__(self, config: ConfigType) -> None:
"""Initialize a text input."""
- self._config = config
- self._current_value = config.get(CONF_INITIAL)
+ self._attr_native_value = config.get(CONF_INITIAL)
+ self._update_config_attributes(config)
+
+ def _update_config_attributes(self, config: ConfigType) -> None:
+ """Update attributes based on the config."""
+ self._attr_icon = config.get(CONF_ICON)
+ self._attr_mode = config[CONF_MODE]
+ self._attr_name = config.get(CONF_NAME)
+ self._attr_native_min = config[CONF_MIN]
+ self._attr_native_max = config[CONF_MAX]
+ self._attr_pattern = config.get(CONF_PATTERN)
+ self._attr_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
+ self._attr_unique_id = config[CONF_ID]
@classmethod
@override
@@ -220,87 +231,42 @@ class InputText(collection.CollectionEntity, RestoreEntity):
input_text.editable = False
return input_text
- @property
- @override
- def name(self) -> str | None:
- """Return the name of the text input entity."""
- return self._config.get(CONF_NAME)
-
- @property
- @override
- def icon(self) -> str | None:
- """Return the icon to be used for this entity."""
- return self._config.get(CONF_ICON)
-
- @property
- def _maximum(self) -> int:
- """Return max len of the text."""
- return self._config[CONF_MAX] # type: ignore[no-any-return]
-
- @property
- def _minimum(self) -> int:
- """Return min len of the text."""
- return self._config[CONF_MIN] # type: ignore[no-any-return]
-
- @property
- @override
- def state(self) -> str | None:
- """Return the state of the component."""
- return self._current_value
-
- @property
- @override
- def unit_of_measurement(self) -> str | None:
- """Return the unit the value is expressed in."""
- return self._config.get(CONF_UNIT_OF_MEASUREMENT)
-
- @property
- @override
- def unique_id(self) -> str:
- """Return unique id for the entity."""
- return self._config[CONF_ID] # type: ignore[no-any-return]
-
@property
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
- return {
- ATTR_EDITABLE: self.editable,
- ATTR_MIN: self._minimum,
- ATTR_MAX: self._maximum,
- ATTR_PATTERN: self._config.get(CONF_PATTERN),
- ATTR_MODE: self._config[CONF_MODE],
- }
+ return {InputTextEntityStateAttribute.EDITABLE: self.editable}
@override
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
- if self._current_value is not None:
+ if self._attr_native_value is not None:
return
state = await self.async_get_last_state()
value = state.state if state else None
# Check against None because value can be 0
- if value is not None and self._minimum <= len(value) <= self._maximum:
- self._current_value = value
+ if value is not None and self.native_min <= len(value) <= self.native_max:
+ self._attr_native_value = value
+ @override
async def async_set_value(self, value: str) -> None:
"""Select new value."""
- if len(value) < self._minimum or len(value) > self._maximum:
+ if len(value) < self.native_min or len(value) > self.native_max:
_LOGGER.warning(
"Invalid value: %s (length range %s - %s)",
value,
- self._minimum,
- self._maximum,
+ self.native_min,
+ self.native_max,
)
return
- self._current_value = value
+ self._attr_native_value = value
self.async_write_ha_state()
@override
async def async_update_config(self, config: ConfigType) -> None:
"""Handle when the config is updated."""
- self._config = config
+ self._update_config_attributes(config)
self.async_write_ha_state()
diff --git a/homeassistant/components/input_text/const.py b/homeassistant/components/input_text/const.py
new file mode 100644
index 000000000000..25760de29eab
--- /dev/null
+++ b/homeassistant/components/input_text/const.py
@@ -0,0 +1,9 @@
+"""Constants for the input_text integration."""
+
+from enum import StrEnum
+
+
+class InputTextEntityStateAttribute(StrEnum):
+ """State attributes for input text entities."""
+
+ EDITABLE = "editable"
diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py
index eb8650dc6490..1a0bf8401f76 100644
--- a/homeassistant/components/integration/__init__.py
+++ b/homeassistant/components/integration/__init__.py
@@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py
index 7d00d2161cc0..718fee7893c5 100644
--- a/homeassistant/components/integration/config_flow.py
+++ b/homeassistant/components/integration/config_flow.py
@@ -8,12 +8,7 @@ import voluptuous as vol
from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
-from homeassistant.const import (
- ATTR_UNIT_OF_MEASUREMENT,
- CONF_METHOD,
- CONF_NAME,
- UnitOfTime,
-)
+from homeassistant.const import CONF_METHOD, CONF_NAME, EntityStateAttribute, UnitOfTime
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
@@ -62,13 +57,16 @@ def entity_selector_compatible(
"""Return an entity selector which compatible entities."""
current = handler.hass.states.get(handler.options[CONF_SOURCE_SENSOR])
unit_of_measurement = (
- current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None
+ current.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ if current
+ else None
)
entities = [
ent.entity_id
for ent in handler.hass.states.async_all(ALLOWED_DOMAINS)
- if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement
+ if ent.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ == unit_of_measurement
and ent.domain in ALLOWED_DOMAINS
]
diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py
index 83b93c4191d0..edd7c5d0c1d0 100644
--- a/homeassistant/components/integration/sensor.py
+++ b/homeassistant/components/integration/sensor.py
@@ -20,12 +20,11 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_DEVICE_CLASS,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_METHOD,
CONF_NAME,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
+ EntityStateAttribute,
UnitOfTime,
)
from homeassistant.core import (
@@ -389,7 +388,9 @@ class IntegrationSensor(RestoreSensor):
return device_class
def _derive_and_set_attributes_from_state(self, source_state: State) -> None:
- source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ source_unit = source_state.attributes.get(
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
+ )
if source_unit is not None:
self._unit_of_measurement = self._calculate_unit(source_unit)
else:
@@ -397,7 +398,8 @@ class IntegrationSensor(RestoreSensor):
self._unit_of_measurement = None
self._attr_device_class = self._calculate_device_class(
- source_state.attributes.get(ATTR_DEVICE_CLASS), self.unit_of_measurement
+ source_state.attributes.get(EntityStateAttribute.DEVICE_CLASS),
+ self.unit_of_measurement,
)
if self._attr_device_class:
# Remove this sensors icon default and allow
diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json
index 4feef90a7f72..ffe8bed9117f 100644
--- a/homeassistant/components/intellifire/manifest.json
+++ b/homeassistant/components/intellifire/manifest.json
@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["intellifire4py"],
- "requirements": ["intellifire4py==4.4.0"]
+ "requirements": ["intellifire4py==4.5.0"]
}
diff --git a/homeassistant/components/intent/llm.py b/homeassistant/components/intent/llm.py
new file mode 100644
index 000000000000..082987914327
--- /dev/null
+++ b/homeassistant/components/intent/llm.py
@@ -0,0 +1,118 @@
+"""LLM tools for the intent integration.
+
+Exposes the generic, cross-domain intents owned by the intent integration
+(device on/off, position, timers) as LLM tools. Domain-specific intents are
+exposed by their own integration's ``llm.py`` platform.
+"""
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import (
+ area_registry as ar,
+ device_registry as dr,
+ floor_registry as fr,
+ intent,
+)
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from .timers import async_device_supports_timers
+
+# Generic intents exposed as LLM tools regardless of a timer-capable device.
+LLM_INTENTS = (
+ intent.INTENT_TURN_ON,
+ intent.INTENT_TURN_OFF,
+ intent.INTENT_CANCEL_ALL_TIMERS,
+ intent.INTENT_SET_POSITION,
+ intent.INTENT_STOP_MOVING,
+)
+
+# Timer intents, only exposed for a device that supports timers.
+TIMER_INTENTS = (
+ intent.INTENT_START_TIMER,
+ intent.INTENT_CANCEL_TIMER,
+ intent.INTENT_INCREASE_TIMER,
+ intent.INTENT_DECREASE_TIMER,
+ intent.INTENT_PAUSE_TIMER,
+ intent.INTENT_UNPAUSE_TIMER,
+ intent.INTENT_TIMER_STATUS,
+)
+
+DEVICE_CONTROL_TOOL_USAGE_PROMPT = (
+ "When controlling Home Assistant always call the intent tools. "
+ "Use HassTurnOn to lock and HassTurnOff to unlock a lock. "
+ "When controlling a device, prefer passing just name and domain. "
+ "When controlling an area, prefer passing just area name and domain."
+)
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return the generic intent tools and the device control prompt."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ supports_timers = (
+ llm_context.device_id is not None
+ and async_device_supports_timers(hass, llm_context.device_id)
+ )
+ wanted = set(LLM_INTENTS)
+ if supports_timers:
+ wanted.update(TIMER_INTENTS)
+
+ exposed_domains = {
+ state.domain
+ for state in hass.states.async_all()
+ if async_should_expose(hass, llm_context.assistant, state.entity_id)
+ }
+ handlers = [
+ handler
+ for handler in intent.async_get(hass)
+ if handler.intent_type in wanted
+ and (handler.platforms is None or handler.platforms & exposed_domains)
+ ]
+
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler) for handler in handlers
+ ]
+ if not tools:
+ return None
+
+ # Only guide device control once something is exposed to control.
+ if not exposed_domains:
+ return LLMTools(tools=tools)
+
+ # Tell the voice satellite which area it is in so generic commands target it.
+ floor: fr.FloorEntry | None = None
+ area: ar.AreaEntry | None = None
+ if llm_context.device_id and (
+ device := dr.async_get(hass).async_get(llm_context.device_id)
+ ):
+ area_reg = ar.async_get(hass)
+ if device.area_id and (area := area_reg.async_get_area(device.area_id)):
+ if area.floor_id:
+ floor = fr.async_get(hass).async_get_floor(area.floor_id)
+
+ if area and floor:
+ area_prompt = (
+ f"You are in area {area.name} (floor {floor.name}) and all generic"
+ " commands like 'turn on the lights' should target this area."
+ )
+ elif area:
+ area_prompt = (
+ f"You are in area {area.name} and all generic commands like"
+ " 'turn on the lights' should target this area."
+ )
+ else:
+ area_prompt = (
+ "When a user asks to turn on all devices of a specific type, "
+ "ask the user to specify an area, unless there is only one device"
+ " of that type."
+ )
+
+ prompt_parts = [DEVICE_CONTROL_TOOL_USAGE_PROMPT, area_prompt]
+ if not supports_timers:
+ prompt_parts.append("This device is not able to start timers.")
+ return LLMTools(tools=tools, prompt="\n".join(prompt_parts))
diff --git a/homeassistant/components/intent_script/llm.py b/homeassistant/components/intent_script/llm.py
new file mode 100644
index 000000000000..bc13382248da
--- /dev/null
+++ b/homeassistant/components/intent_script/llm.py
@@ -0,0 +1,51 @@
+"""LLM tools for the intent_script integration."""
+
+import slugify as unicode_slug
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from . import ScriptIntentHandler
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return an LLM tool for each configured intent script."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ handlers = [
+ handler
+ for handler in intent.async_get(hass)
+ if isinstance(handler, ScriptIntentHandler)
+ ]
+
+ exposed_domains = {
+ state.domain
+ for state in hass.states.async_all()
+ if async_should_expose(hass, llm_context.assistant, state.entity_id)
+ }
+ handlers = [
+ handler
+ for handler in handlers
+ if handler.platforms is None or handler.platforms & exposed_domains
+ ]
+
+ if not handlers:
+ return None
+
+ # Intent script names come from user configuration, so slugify them into
+ # valid tool names.
+ tools: list[Tool] = [
+ IntentTool(
+ unicode_slug.slugify(handler.intent_type, separator="_", lowercase=False),
+ handler,
+ )
+ for handler in handlers
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py
index e751f511043b..d2089c236503 100644
--- a/homeassistant/components/iotawatt/coordinator.py
+++ b/homeassistant/components/iotawatt/coordinator.py
@@ -78,6 +78,9 @@ class IotawattUpdater(DataUpdateCoordinator):
self.api = api
- await self.api.update(lastUpdate=self._last_run)
+ try:
+ await self.api.update(lastUpdate=self._last_run)
+ except CONNECTION_ERRORS as err:
+ raise UpdateFailed("Connection failed") from err
self._last_run = None
return self.api.getSensors()
diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json
index f6f9efb16320..ac0d705ab898 100644
--- a/homeassistant/components/iotawatt/manifest.json
+++ b/homeassistant/components/iotawatt/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["iotawattpy"],
- "requirements": ["ha-iotawattpy==0.1.2"]
+ "requirements": ["ha-iotawattpy==0.2.1"]
}
diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py
index 628781f6bd88..e6ea6160ffdf 100644
--- a/homeassistant/components/iqvia/config_flow.py
+++ b/homeassistant/components/iqvia/config_flow.py
@@ -1,4 +1,4 @@
-"""Config flow to configure the IQVIA component."""
+"""Config flow to configure the IQVIA integration."""
from typing import Any, override
diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py
index e87711263460..2fd0c0dc7ad7 100644
--- a/homeassistant/components/iron_os/update.py
+++ b/homeassistant/components/iron_os/update.py
@@ -3,11 +3,11 @@
from typing import override
from homeassistant.components.update import (
- ATTR_INSTALLED_VERSION,
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
+ UpdateEntityStateAttribute,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
@@ -97,7 +97,9 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity, RestoreEntity):
Register extra update listener for the firmware update coordinator.
"""
if state := await self.async_get_last_state():
- self._attr_installed_version = state.attributes.get(ATTR_INSTALLED_VERSION)
+ self._attr_installed_version = state.attributes.get(
+ UpdateEntityStateAttribute.INSTALLED_VERSION
+ )
await super().async_added_to_hass()
self.async_on_remove(
diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py
index 0241d1b4e160..980fa166e727 100644
--- a/homeassistant/components/iss/sensor.py
+++ b/homeassistant/components/iss/sensor.py
@@ -4,7 +4,7 @@ import logging
from typing import Any, override
from homeassistant.components.sensor import SensorEntity
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP
+from homeassistant.const import CONF_SHOW_ON_MAP, EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -61,16 +61,13 @@ class IssSensor(CoordinatorEntity[IssDataUpdateCoordinator], SensorEntity):
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
- attrs = {}
+ attrs: dict[str, Any] = {}
+ location = self.coordinator.data.current_location
if self._show_on_map:
- attrs[ATTR_LONGITUDE] = self.coordinator.data.current_location.get(
- "longitude"
- )
- attrs[ATTR_LATITUDE] = self.coordinator.data.current_location.get(
- "latitude"
- )
+ attrs[EntityStateAttribute.LONGITUDE] = location.get("longitude")
+ attrs[EntityStateAttribute.LATITUDE] = location.get("latitude")
else:
- attrs["long"] = self.coordinator.data.current_location.get("longitude")
- attrs["lat"] = self.coordinator.data.current_location.get("latitude")
+ attrs["long"] = location.get("longitude")
+ attrs["lat"] = location.get("latitude")
return attrs
diff --git a/homeassistant/components/ituran/coordinator.py b/homeassistant/components/ituran/coordinator.py
index 2664e3e12d25..0cd48b263f05 100644
--- a/homeassistant/components/ituran/coordinator.py
+++ b/homeassistant/components/ituran/coordinator.py
@@ -73,6 +73,4 @@ class IturanDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]):
)
for device in device_entries:
if not device.identifiers.intersection(account_vehicles):
- device_registry.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_registry.async_remove_device(device.id)
diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py
index 9f5e0d2e9c36..c3d17460e31d 100644
--- a/homeassistant/components/izone/__init__.py
+++ b/homeassistant/components/izone/__init__.py
@@ -4,13 +4,14 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform
+from homeassistant.const import CONF_EXCLUDE, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
+from . import discovery
from .const import DATA_CONFIG, DOMAIN
-from .discovery import async_start_discovery_service, async_stop_discovery_service
PLATFORMS = [Platform.CLIMATE]
@@ -30,35 +31,101 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the iZone component config."""
-
- # Check for manually added config, this may exclude some devices
if conf := config.get(DOMAIN):
hass.data[DATA_CONFIG] = conf
- # Explicitly added in the config file, create a config entry.
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
)
)
- # Start the discovery service
- await async_start_discovery_service(hass)
-
- async def shutdown_event(event):
- await async_stop_discovery_service(hass)
-
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event)
-
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry."""
+ try:
+ await discovery.async_start_discovery_service(hass)
+ except OSError as err:
+ raise ConfigEntryNotReady("iZone discovery service failed to start") from err
+
+ if entry.unique_id == DOMAIN:
+ # Legacy v1-migrated entry: resolve to a real controller UID at setup time.
+ #
+ # Doing this work here (rather than in async_migrate_entry) is intentional:
+ # ConfigEntryNotReady raised from async_migrate_entry becomes a permanent
+ # MIGRATION_ERROR — HA does not retry failed migrations. Raising it from
+ # async_setup_entry correctly schedules a retry on the next HA start.
+ #
+ # Raising ConfigEntryError (multiple eligible controllers) is permanent either
+ # way; those controllers are not lost — the discovery fan-out will surface them
+ # as individual flows once HA restarts. This is not a breaking change: a v1
+ # entry with multiple controllers was already broken before this PR.
+ # async_discover_controllers reuses the already-running service (idempotent
+ # start), so OSError here means fetch_controllers() itself failed — rare but
+ # kept as a defensive guard.
+ try:
+ controllers = await discovery.async_discover_controllers(hass)
+ except OSError as err:
+ raise ConfigEntryNotReady(
+ "iZone discovery failed while resolving legacy config entry"
+ ) from err
+
+ conf: ConfigType | None = hass.data.get(DATA_CONFIG)
+ excluded_uids: set[str] = set(conf.get(CONF_EXCLUDE, [])) if conf else set()
+ configured_uids = {
+ config_entry.unique_id
+ for config_entry in hass.config_entries.async_entries(DOMAIN)
+ if config_entry.entry_id != entry.entry_id
+ and config_entry.unique_id not in (None, DOMAIN)
+ }
+ eligible = [
+ controller
+ for controller in controllers.values()
+ if controller.device_uid not in excluded_uids
+ and controller.device_uid not in configured_uids
+ ]
+
+ if not eligible:
+ raise ConfigEntryNotReady(
+ "No eligible iZone controller found to bind to legacy config entry"
+ )
+
+ if len(eligible) > 1:
+ raise ConfigEntryError(
+ "Multiple eligible iZone controllers found for a legacy config entry; "
+ "delete this entry and re-add each controller individually"
+ )
+
+ controller = eligible[0]
+ new_title = (
+ f"iZone {controller.device_uid}"
+ if entry.title == "iZone Aircon"
+ else entry.title
+ )
+ hass.config_entries.async_update_entry(
+ entry,
+ unique_id=controller.device_uid,
+ title=new_title,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Migrate old config entry schema to the current version."""
+ if entry.version == 1:
+ # Clear legacy data only — UID and title binding is deferred to
+ # async_setup_entry where ConfigEntryNotReady retry semantics work correctly.
+ # Raising ConfigEntryNotReady from async_migrate_entry would permanently land
+ # the entry in MIGRATION_ERROR with no retry path.
+ hass.config_entries.async_update_entry(entry, version=2, data={})
+ return True
+ return False
+
+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Unload the config entry and stop discovery process."""
+ """Unload the config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py
index a1cbf3078fc5..74fab9e02799 100644
--- a/homeassistant/components/izone/climate.py
+++ b/homeassistant/components/izone/climate.py
@@ -22,7 +22,6 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
- CONF_EXCLUDE,
PRECISION_HALVES,
PRECISION_TENTHS,
UnitOfTemperature,
@@ -33,10 +32,9 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.temperature import display_temp as show_temp
-from homeassistant.helpers.typing import ConfigType, VolDictType
+from homeassistant.helpers.typing import VolDictType
from .const import (
- DATA_CONFIG,
DATA_DISCOVERY_SERVICE,
DISPATCH_CONTROLLER_DISCONNECTED,
DISPATCH_CONTROLLER_DISCOVERED,
@@ -44,6 +42,7 @@ from .const import (
DISPATCH_CONTROLLER_UPDATE,
DISPATCH_ZONE_UPDATE,
DOMAIN,
+ TIMEOUT_DISCOVERY,
)
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R]
@@ -77,25 +76,29 @@ async def async_setup_entry(
) -> None:
"""Initialize an IZone Controller."""
disco = hass.data[DATA_DISCOVERY_SERVICE]
+ entry_unique_id = config.unique_id
+ initialized = False
@callback
def init_controller(ctrl: Controller):
"""Register the controller device and the containing zones."""
- conf: ConfigType | None = hass.data.get(DATA_CONFIG)
-
- # Filter out any entities excluded in the config file
- if conf and ctrl.device_uid in conf[CONF_EXCLUDE]:
- _LOGGER.debug("Controller UID=%s ignored as excluded", ctrl.device_uid)
+ nonlocal initialized
+ if entry_unique_id and ctrl.device_uid != entry_unique_id:
+ return
+ if initialized:
return
- _LOGGER.debug("Controller UID=%s discovered", ctrl.device_uid)
+ initialized = True
device = ControllerDevice(ctrl)
async_add_entities([device])
async_add_entities(device.zones.values())
+ _LOGGER.debug("Controller UID=%s initialized", ctrl.device_uid)
- # create any components not yet created
- for controller in (await disco.pi_disco.fetch_controllers()).values():
- init_controller(controller)
+ # Fetch the controller for this entry, waiting for discovery if it hasn't been found yet
+ if ctrl := await disco.pi_disco.fetch_controller(
+ entry_unique_id, timeout=TIMEOUT_DISCOVERY
+ ):
+ init_controller(ctrl)
# connect to register any further components
config.async_on_unload(
@@ -305,10 +308,10 @@ class ControllerDevice(ClimateEntity):
"""Return current operation ie. heat, cool, idle."""
if not self._controller.is_on:
return HVACMode.OFF
- if (mode := self._controller.mode) == Controller.Mode.FREE_AIR:
+ if (mode := self._controller.mode) is Controller.Mode.FREE_AIR:
return HVACMode.FAN_ONLY
for key, value in self._state_to_pizone.items():
- if value == mode:
+ if value is mode:
return key
raise RuntimeError("Should be unreachable")
@@ -342,7 +345,7 @@ class ControllerDevice(ClimateEntity):
@override
def current_temperature(self) -> float | None:
"""Return the current temperature."""
- if self._controller.mode == Controller.Mode.FREE_AIR:
+ if self._controller.mode is Controller.Mode.FREE_AIR:
return self._controller.temp_supply
return self._controller.temp_return
@@ -387,7 +390,7 @@ class ControllerDevice(ClimateEntity):
return self.control_zone_setpoint
@property
- def supply_temperature(self) -> float:
+ def supply_temperature(self) -> float | None:
"""Return the current supply, or in duct, temperature."""
return self._controller.temp_supply
@@ -485,7 +488,7 @@ class ZoneDevice(ClimateEntity):
self._controller = controller
self._zone = zone
- if zone.type != Zone.Type.AUTO:
+ if zone.type is not Zone.Type.AUTO:
self._state_to_pizone = {
HVACMode.OFF: Zone.Mode.CLOSE,
HVACMode.FAN_ONLY: Zone.Mode.OPEN,
@@ -552,7 +555,7 @@ class ZoneDevice(ClimateEntity):
@override
def supported_features(self) -> ClimateEntityFeature:
"""Return the list of supported features."""
- if self._zone.mode == Zone.Mode.AUTO:
+ if self._zone.mode is Zone.Mode.AUTO:
return self._attr_supported_features
return self._attr_supported_features & ~ClimateEntityFeature.TARGET_TEMPERATURE
@@ -562,7 +565,7 @@ class ZoneDevice(ClimateEntity):
"""Return current operation ie. heat, cool, idle."""
mode = self._zone.mode
for key, value in self._state_to_pizone.items():
- if value == mode:
+ if value is mode:
return key
return None
@@ -574,7 +577,7 @@ class ZoneDevice(ClimateEntity):
@property
@override
- def current_temperature(self) -> float:
+ def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._zone.temp_current
@@ -582,7 +585,7 @@ class ZoneDevice(ClimateEntity):
@override
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
- if self._zone.type != Zone.Type.AUTO:
+ if self._zone.type is not Zone.Type.AUTO:
return None
return self._zone.temp_setpoint
@@ -625,7 +628,7 @@ class ZoneDevice(ClimateEntity):
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- if self._zone.mode != Zone.Mode.AUTO:
+ if self._zone.mode is not Zone.Mode.AUTO:
return
if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
await self._controller.wrap_and_catch(self._zone.set_temp_setpoint(temp))
@@ -640,12 +643,12 @@ class ZoneDevice(ClimateEntity):
@property
def is_on(self) -> bool:
"""Return true if on."""
- return self._zone.mode != Zone.Mode.CLOSE
+ return self._zone.mode is not Zone.Mode.CLOSE
@override
async def async_turn_on(self) -> None:
"""Turn device on (open zone)."""
- if self._zone.type == Zone.Type.AUTO:
+ if self._zone.type is Zone.Type.AUTO:
await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.AUTO))
else:
await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.OPEN))
diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py
index 762949b7e184..8525473c34df 100644
--- a/homeassistant/components/izone/config_flow.py
+++ b/homeassistant/components/izone/config_flow.py
@@ -1,42 +1,387 @@
"""Config flow for izone."""
-import asyncio
-from contextlib import suppress
+from collections.abc import Iterable
import logging
+from typing import Any, Self, override
+import pizone
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_entry_flow
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers import discovery_flow
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
+from homeassistant.helpers.typing import DiscoveryInfoType
-from .const import DISPATCH_CONTROLLER_DISCOVERED, DOMAIN, TIMEOUT_DISCOVERY
-from .discovery import async_start_discovery_service, async_stop_discovery_service
+from . import discovery as izone_discovery
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+SELECTED_CONTROLLER_UID = "selected_controller_uid"
-async def _async_has_devices(hass: HomeAssistant) -> bool:
- controller_ready = asyncio.Event()
+
+def _flow_uid_for_matching(flow: ConfigFlow) -> str | None:
+ """Return a stable controller UID for deduplicating in-progress flows."""
+ ctx_uid = flow.context.get("unique_id")
+ if isinstance(ctx_uid, str):
+ return ctx_uid
+ return None
+
+
+class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Config flow: user, YAML import, HomeKit, and integration discovery."""
+
+ VERSION = 2
+
+ _user_discovered_controllers: list[pizone.Controller] | None = None
+ _discovered_controller_ip: str | None = None
+
+ @override
+ def is_matching(self, other_flow: Self) -> bool:
+ """Match in-progress flows for the same controller UID."""
+ self_uid = _flow_uid_for_matching(self)
+ other_uid = _flow_uid_for_matching(other_flow)
+ if self_uid is None or other_uid is None:
+ return False
+ return self_uid == other_uid
+
+ # -- User-visible and internal steps (roughly: import → user → discovery UI → HK → fan-out → confirm)
+
+ async def async_step_import(
+ self, _import_data: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """YAML import: start discovery and let runtime discovery offer flows.
+
+ The import step runs exactly once (Home Assistant calls it only when the YAML
+ key is present at startup). We start the discovery service so every controller
+ surfaced by the service's normal listener appears in discovered devices and
+ still requires normal confirmation.
+
+ No explicit rescan is issued: the service will broadcast its own discovery
+ request as part of start-up, and the import step itself will not be repeated.
+ """
+ if self._async_in_progress(include_uninitialized=True):
+ return self.async_abort(reason="already_in_progress")
+
+ try:
+ await izone_discovery.async_start_discovery_service(self.hass)
+ except OSError:
+ _LOGGER.debug("Unable to start iZone discovery from import", exc_info=True)
+ return self.async_abort(reason="discovery_failed")
+
+ # Discovery is now running; each controller will surface as an individual
+ # integration_discovery flow. Use a dedicated abort reason so the UI does
+ # not misleadingly show "No devices found" when setup is actually in progress.
+ return self.async_abort(reason="discovery_started")
+
+ @override
+ async def async_step_user(
+ self, _user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """User-started flow: offer configuration choices for discovered controllers.
+
+ Discovery is started if not yet running, then a fresh discovery cycle is triggered
+ and this step waits briefly for replies. The pizone library's built-in coalescing
+ avoids redundant broadcasts when discovery was just started.
+
+ While this interactive flow is active, runtime integration discovery remains
+ blocked by ``_async_blocks_runtime_integration_discovery`` to avoid UI races.
+ """
+
+ if self._async_in_progress(include_uninitialized=True):
+ return self.async_abort(reason="already_in_progress")
+
+ try:
+ controllers = await izone_discovery.async_discover_controllers(
+ self.hass, refresh=True
+ )
+ except OSError:
+ _LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
+ return self.async_abort(reason="discovery_failed")
+ if not controllers:
+ _LOGGER.debug("No controllers found")
+ return self.async_abort(reason="no_devices_found")
+
+ self._user_discovered_controllers = self._async_get_unconfigured_controllers(
+ controllers
+ )
+ if not self._user_discovered_controllers:
+ return self.async_abort(reason="already_configured")
+ if len(self._user_discovered_controllers) > 1:
+ return await self.async_step_select_controller()
+
+ sole = self._user_discovered_controllers[0]
+ await self.async_set_unique_id(sole.device_uid)
+ self._discovered_controller_ip = sole.device_ip
+ return await self.async_step_confirm()
+
+ async def async_step_select_controller(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Choose one unconfigured controller after broadcast discovery."""
+ if not self._user_discovered_controllers:
+ return self.async_abort(reason="no_devices_found")
+
+ by_uid = {
+ controller.device_uid: controller
+ for controller in self._user_discovered_controllers
+ }
+ selection_schema = vol.Schema(
+ {
+ vol.Required(
+ SELECTED_CONTROLLER_UID,
+ default=self._user_discovered_controllers[0].device_uid,
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(
+ value=controller.device_uid,
+ label=(
+ f"{controller.device_uid} ({controller.device_ip})"
+ ),
+ )
+ for controller in self._user_discovered_controllers
+ ],
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ )
+ }
+ )
+
+ if user_input is not None:
+ selected_uid = user_input[SELECTED_CONTROLLER_UID]
+ if (primary := by_uid.get(selected_uid)) is None:
+ return self.async_abort(reason="no_devices_found")
+
+ for ctrl in self._user_discovered_controllers:
+ if ctrl.device_uid == primary.device_uid:
+ continue
+ # Using integration_discovery lets HA's deduplication guard prevent stacking
+ # flows for UIDs already in progress or already configured.
+ self._async_schedule_integration_discovery_flow(
+ ctrl.device_uid,
+ ctrl.device_ip,
+ )
+ return await self._async_create_controller_entry(primary)
+
+ controllers_lines = "\n".join(
+ f"- {controller.device_uid} ({controller.device_ip})"
+ for controller in self._user_discovered_controllers
+ )
+ return self.async_show_form(
+ step_id="select_controller",
+ data_schema=selection_schema,
+ description_placeholders={"controllers": controllers_lines},
+ )
+
+ @override
+ async def async_step_homekit(
+ self, discovery_info: ZeroconfServiceInfo
+ ) -> ConfigFlowResult:
+ """Map HomeKit ``md`` to an iZone UID, discover LAN controllers, then confirm."""
+ model = discovery_info.properties.get("md", "")
+ if not model.startswith("iZone "):
+ return self.async_abort(reason="no_devices_found")
+
+ device_uid = model.split(" ", 1)[1]
+
+ if device_uid in izone_discovery.yaml_excluded_uids(self.hass):
+ return self.async_abort(reason="no_devices_found")
+
+ # async_set_unique_id + _abort_if_unique_id_configured handles both existing
+ # entries (including SOURCE_IGNORE) and stale in-progress flows for this UID.
+ # A direct async_entry_for_domain_unique_id pre-check would miss the
+ # flow-deduplication side effect of async_set_unique_id(raise_on_progress=True).
+ await self.async_set_unique_id(device_uid)
+ self._abort_if_unique_id_configured()
+
+ # A HomeKit advertisement implies a specific UID is on the LAN. Wait for it.
+ try:
+ controllers = await izone_discovery.async_discover_controllers(
+ self.hass,
+ refresh=True,
+ wait_for_uid=device_uid,
+ )
+ except OSError:
+ _LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
+ return self.async_abort(reason="discovery_failed")
+ controller = controllers.get(device_uid)
+ if controller is None:
+ return self.async_abort(reason="no_devices_found")
+
+ self._discovered_controller_ip = controller.device_ip
+
+ # Re-check after awaiting discovery to catch mid-flight configuration.
+ self._abort_if_unique_id_configured()
+
+ self._async_fan_out_discovered_controllers(
+ controllers.values(),
+ selected_uid=device_uid,
+ )
+
+ return await self.async_step_confirm()
+
+ @override
+ async def async_step_integration_discovery(
+ self, discovery_info: DiscoveryInfoType
+ ) -> ConfigFlowResult:
+ """Handle fan-out, YAML import secondaries, and runtime discovery."""
+ uid = self.context["unique_id"]
+ host = discovery_info[CONF_HOST]
+ if uid in izone_discovery.yaml_excluded_uids(self.hass):
+ return self.async_abort(reason="no_devices_found")
+
+ await self.async_set_unique_id(uid)
+ self._abort_if_unique_id_configured()
+ # Discovery host is for confirm-step context only; runtime discovery owns
+ # current device IP state and keeps it up to date independently of entry data.
+ self._discovered_controller_ip = host
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm adding a controller found via HomeKit or manual host."""
+ if user_input is None:
+ controller_uid = self.unique_id
+ host = self._discovered_controller_ip
+ assert isinstance(controller_uid, str)
+ assert controller_uid
+ assert host is not None
+ host_str = str(host)
+ self.context["title_placeholders"] = {
+ "name": self._entry_title(controller_uid),
+ }
+ return self.async_show_form(
+ step_id="confirm",
+ description_placeholders={
+ "controller_uid": controller_uid,
+ "host": host_str,
+ },
+ )
+
+ try:
+ controllers = await izone_discovery.async_discover_controllers(self.hass)
+ except OSError:
+ _LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
+ return self.async_abort(reason="discovery_failed")
+ if not controllers:
+ _LOGGER.debug("No controllers found")
+ return self.async_abort(reason="no_devices_found")
+
+ uid = self.unique_id
+ assert isinstance(uid, str)
+
+ controller = controllers.get(uid)
+ if controller is None:
+ _LOGGER.debug(
+ "Discovered controller UID %s was not found during confirmation",
+ uid,
+ )
+ return self.async_abort(reason="no_devices_found")
+ return await self._async_create_controller_entry(
+ controller,
+ )
+
+ # -- Private helpers
@callback
- def dispatch_discovered(_):
- controller_ready.set()
+ def _async_schedule_integration_discovery_flow(
+ self,
+ uid: str,
+ host: str,
+ ) -> None:
+ """Queue integration discovery (import fan-out or manual discovery pick)."""
+ discovery_flow.async_create_flow(
+ self.hass,
+ DOMAIN,
+ context={
+ "source": config_entries.SOURCE_INTEGRATION_DISCOVERY,
+ "unique_id": uid,
+ },
+ data={CONF_HOST: host},
+ )
- async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, dispatch_discovered)
+ @staticmethod
+ def _entry_title(device_uid: str) -> str:
+ """Standard config entry title for a controller UID."""
+ return f"iZone {device_uid}"
- disco = await async_start_discovery_service(hass)
+ @staticmethod
+ def _filter_yaml_exclude(
+ hass: HomeAssistant, controllers: dict[str, pizone.Controller]
+ ) -> dict[str, pizone.Controller]:
+ """Remove UIDs listed in deprecated YAML ``exclude``."""
+ excluded = izone_discovery.yaml_excluded_uids(hass)
+ if not excluded:
+ return controllers
+ return {
+ uid: ctrl
+ for uid, ctrl in controllers.items()
+ if ctrl.device_uid not in excluded
+ }
- with suppress(TimeoutError):
- async with asyncio.timeout(TIMEOUT_DISCOVERY):
- await controller_ready.wait()
+ @callback
+ def _async_get_unconfigured_controllers(
+ self, controllers: dict[str, pizone.Controller]
+ ) -> list[pizone.Controller]:
+ """Return sorted unconfigured controllers for the interactive user flow."""
+ controllers = self._filter_yaml_exclude(self.hass, controllers)
+ # include_ignore=True ensures controllers whose entries have been explicitly
+ # ignored by the user (SOURCE_IGNORE) are not re-offered as configurable.
+ configured_uids = self._async_current_ids(include_ignore=True)
+ return sorted(
+ (
+ controller
+ for controller in controllers.values()
+ if controller.device_uid not in configured_uids
+ ),
+ key=lambda controller: (controller.device_uid, controller.device_ip),
+ )
- controllers = await disco.pi_disco.fetch_controllers()
- if not controllers:
- await async_stop_discovery_service(hass)
- _LOGGER.debug("No controllers found")
- return False
+ async def _async_create_controller_entry(
+ self,
+ controller: pizone.Controller,
+ ) -> ConfigFlowResult:
+ """Create the config entry for a chosen :class:`pizone.Controller` instance."""
+ await self.async_set_unique_id(controller.device_uid)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=self._entry_title(controller.device_uid),
+ data={},
+ )
- _LOGGER.debug("Controllers %s", controllers)
- return True
-
-
-config_entry_flow.register_discovery_flow(DOMAIN, "iZone Aircon", _async_has_devices)
+ @callback
+ def _async_fan_out_discovered_controllers(
+ self,
+ controllers: Iterable[pizone.Controller],
+ *,
+ selected_uid: str,
+ ) -> None:
+ """Start confirm flows for every other discovered UID (import uses its own path)."""
+ current_ids = self._async_current_ids(include_ignore=True)
+ in_progress_ids = {
+ flow["context"].get("unique_id")
+ for flow in self._async_in_progress(include_uninitialized=True)
+ }
+ for candidate in controllers:
+ if candidate.device_uid == selected_uid:
+ continue
+ if (
+ candidate.device_uid in current_ids
+ or candidate.device_uid in in_progress_ids
+ ):
+ continue
+ self._async_schedule_integration_discovery_flow(
+ candidate.device_uid,
+ candidate.device_ip,
+ )
diff --git a/homeassistant/components/izone/const.py b/homeassistant/components/izone/const.py
index 99d75bf92b54..eb8f76ed1daa 100644
--- a/homeassistant/components/izone/const.py
+++ b/homeassistant/components/izone/const.py
@@ -11,4 +11,5 @@ DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_reconnected"
DISPATCH_CONTROLLER_UPDATE = "izone_controller_update"
DISPATCH_ZONE_UPDATE = "izone_zone_update"
-TIMEOUT_DISCOVERY = 20
+TIMEOUT_DISCOVERY = 5
+DISCOVERY_IDLE_SECONDS = 4 * TIMEOUT_DISCOVERY
diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py
index 81537bbdbbcf..862577307c58 100644
--- a/homeassistant/components/izone/discovery.py
+++ b/homeassistant/components/izone/discovery.py
@@ -1,25 +1,108 @@
-"""Internal discovery service for iZone AC."""
+"""Internal discovery service for iZone AC."""
+import asyncio
+from collections.abc import Callable
import logging
+from typing import override
import pizone
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import aiohttp_client
-from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant import config_entries
+from homeassistant.const import CONF_EXCLUDE, CONF_HOST, EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.helpers import aiohttp_client, discovery_flow
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
+from homeassistant.helpers.typing import ConfigType
from .const import (
+ DATA_CONFIG,
DATA_DISCOVERY_SERVICE,
+ DISCOVERY_IDLE_SECONDS,
DISPATCH_CONTROLLER_DISCONNECTED,
DISPATCH_CONTROLLER_DISCOVERED,
DISPATCH_CONTROLLER_RECONNECTED,
DISPATCH_CONTROLLER_UPDATE,
DISPATCH_ZONE_UPDATE,
+ DOMAIN,
+ TIMEOUT_DISCOVERY,
)
_LOGGER = logging.getLogger(__name__)
+async def async_discover_controllers(
+ hass: HomeAssistant,
+ *,
+ refresh: bool = False,
+ wait_for_uid: str | None = None,
+) -> dict[str, pizone.Controller]:
+ """Return currently known controllers, optionally waiting for a UID during rescan.
+
+ If ``refresh`` is true, waits for fresh discovery data using the pizone library's
+ built-in coalescing and cool-down logic. When ``wait_for_uid`` is provided, returns
+ as soon as that specific controller appears (or after the timeout).
+
+ If discovery is not yet running, it is started first.
+
+ Raises:
+ OSError: Discovery service failed to start or controller fetch failed.
+ """
+ disco = await async_start_discovery_service(hass)
+ assert disco.pi_disco is not None
+
+ if not refresh:
+ return await disco.pi_disco.fetch_controllers()
+
+ if wait_for_uid is not None:
+ await disco.pi_disco.fetch_controller(wait_for_uid, timeout=TIMEOUT_DISCOVERY)
+ return await disco.pi_disco.fetch_controllers()
+
+ return await disco.pi_disco.fetch_controllers(timeout=TIMEOUT_DISCOVERY)
+
+
+def yaml_excluded_uids(hass: HomeAssistant) -> set[str]:
+ """Return controller UIDs listed in deprecated YAML ``exclude``."""
+ conf: ConfigType | None = hass.data.get(DATA_CONFIG)
+ if not conf:
+ return set()
+ return set(conf.get(CONF_EXCLUDE, ()))
+
+
+@callback
+def async_note_integration_discovery(
+ hass: HomeAssistant, ctrl: pizone.Controller
+) -> None:
+ """Start a config flow when the shared discovery service reports a controller."""
+ if ctrl.device_uid in yaml_excluded_uids(hass):
+ return
+ if _async_blocks_runtime_integration_discovery(hass):
+ return
+ discovery_flow.async_create_flow(
+ hass,
+ DOMAIN,
+ context={
+ "source": config_entries.SOURCE_INTEGRATION_DISCOVERY,
+ "unique_id": ctrl.device_uid,
+ },
+ data={CONF_HOST: ctrl.device_ip},
+ )
+
+
+@callback
+def _async_blocks_runtime_integration_discovery(hass: HomeAssistant) -> bool:
+ """Return True when an interactive setup flow should own the UI."""
+ for flw in hass.config_entries.flow.async_progress_by_handler(
+ DOMAIN, include_uninitialized=True
+ ):
+ src = flw["context"].get("source")
+ if src == config_entries.SOURCE_USER:
+ return True
+ return False
+
+
class DiscoveryService(pizone.Listener):
"""Discovery data and interfacing with pizone library."""
@@ -28,30 +111,59 @@ class DiscoveryService(pizone.Listener):
super().__init__()
self.hass = hass
self.pi_disco: pizone.DiscoveryService | None = None
+ self.remove_stop_listener: Callable[[], None] | None = None
+ self.remove_config_flow_listener: Callable[[], None] | None = None
+ self._idle_stop_handle: asyncio.TimerHandle | None = None
+
+ @callback
+ def async_schedule_idle_stop(self) -> None:
+ """Schedule a delayed shutdown check for discovery service."""
+ if self._idle_stop_handle is not None:
+ self._idle_stop_handle.cancel()
+
+ self._idle_stop_handle = self.hass.loop.call_later(
+ DISCOVERY_IDLE_SECONDS,
+ lambda: self.hass.async_create_task(
+ async_maybe_stop_discovery_service(self.hass)
+ ),
+ )
+
+ @callback
+ def async_cancel_idle_stop(self) -> None:
+ """Cancel any pending idle-stop timer."""
+ if self._idle_stop_handle is not None:
+ self._idle_stop_handle.cancel()
+ self._idle_stop_handle = None
# Listener interface
+ @override
def controller_discovered(self, ctrl: pizone.Controller) -> None:
"""Handle new controller discovery."""
+ self.async_schedule_idle_stop()
async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCOVERED, ctrl)
+ @override
def controller_disconnected(self, ctrl: pizone.Controller, ex: Exception) -> None:
"""On disconnect from controller."""
async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCONNECTED, ctrl, ex)
+ @override
def controller_reconnected(self, ctrl: pizone.Controller) -> None:
"""On reconnect to controller."""
async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_RECONNECTED, ctrl)
+ @override
def controller_update(self, ctrl: pizone.Controller) -> None:
"""System update message is received from the controller."""
async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_UPDATE, ctrl)
+ @override
def zone_update(self, ctrl: pizone.Controller, zone: pizone.Zone) -> None:
"""Zone update message is received from the controller."""
async_dispatcher_send(self.hass, DISPATCH_ZONE_UPDATE, ctrl, zone)
-async def async_start_discovery_service(hass: HomeAssistant):
+async def async_start_discovery_service(hass: HomeAssistant) -> DiscoveryService:
"""Set up the pizone internal discovery."""
if disco := hass.data.get(DATA_DISCOVERY_SERVICE):
# Already started
@@ -60,21 +172,116 @@ async def async_start_discovery_service(hass: HomeAssistant):
# discovery local services
disco = DiscoveryService(hass)
- hass.data[DATA_DISCOVERY_SERVICE] = disco
# Start the pizone discovery service, disco is the listener
session = aiohttp_client.async_get_clientsession(hass)
disco.pi_disco = pizone.discovery(disco, session=session)
+
+ @callback
+ def _async_on_controller_discovered(ctrl: pizone.Controller) -> None:
+ async_note_integration_discovery(hass, ctrl)
+
+ disco.remove_config_flow_listener = async_dispatcher_connect(
+ hass, DISPATCH_CONTROLLER_DISCOVERED, _async_on_controller_discovered
+ )
+
await disco.pi_disco.start_discovery()
+ # Stored after start_discovery() so concurrent callers never receive a
+ # partially-initialised DiscoveryService (no active UDP transport or scan loop).
+ hass.data[DATA_DISCOVERY_SERVICE] = disco
+
+ async def async_stop_discovery_on_shutdown(event: Event) -> None:
+ """Stop discovery on Home Assistant shutdown."""
+ # async_listen_once removes its own listener before running this callback.
+ # Clear our handle so async_stop_discovery_service does not try to remove it
+ # a second time, which logs an "unknown job listener" error.
+ disco.remove_stop_listener = None
+ await async_stop_discovery_service(hass)
+
+ disco.remove_stop_listener = hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, async_stop_discovery_on_shutdown
+ )
+ disco.async_schedule_idle_stop()
return disco
-async def async_stop_discovery_service(hass: HomeAssistant):
+@callback
+def _async_is_ignored_or_excluded_uid(hass: HomeAssistant, uid: str) -> bool:
+ """Return True when UID is excluded by YAML or ignored/disabled by config entries."""
+ if uid in yaml_excluded_uids(hass):
+ return True
+
+ return any(
+ entry.unique_id == uid
+ and (
+ entry.source == config_entries.SOURCE_IGNORE
+ or entry.disabled_by is not None
+ )
+ for entry in hass.config_entries.async_entries(DOMAIN)
+ )
+
+
+@callback
+def _async_has_actionable_entries(hass: HomeAssistant) -> bool:
+ """Return True when there is at least one enabled, non-ignored iZone entry."""
+ return any(
+ hass.config_entries.async_entries(
+ DOMAIN, include_ignore=False, include_disabled=False
+ )
+ )
+
+
+@callback
+def _async_has_actionable_flows(hass: HomeAssistant) -> bool:
+ """Return True when there is an in-progress iZone flow that can create/update state."""
+ return any(
+ flow["context"].get("source") != config_entries.SOURCE_IGNORE
+ for flow in hass.config_entries.flow.async_progress_by_handler(
+ DOMAIN, include_uninitialized=True
+ )
+ )
+
+
+async def async_maybe_stop_discovery_service(hass: HomeAssistant) -> None:
+ """Stop discovery after idle delay when no actionable controllers remain."""
+ if not (disco := hass.data.get(DATA_DISCOVERY_SERVICE)):
+ return
+
+ if _async_has_actionable_flows(hass) or _async_has_actionable_entries(hass):
+ disco.async_schedule_idle_stop()
+ return
+
+ controllers_map = await disco.pi_disco.fetch_controllers()
+ if not controllers_map:
+ await async_stop_discovery_service(hass)
+ return
+
+ if all(
+ _async_is_ignored_or_excluded_uid(hass, c.device_uid)
+ for c in controllers_map.values()
+ ):
+ await async_stop_discovery_service(hass)
+ return
+
+ disco.async_schedule_idle_stop()
+
+
+async def async_stop_discovery_service(hass: HomeAssistant) -> None:
"""Stop the discovery service."""
if not (disco := hass.data.get(DATA_DISCOVERY_SERVICE)):
return
+ if disco.remove_stop_listener is not None:
+ disco.remove_stop_listener()
+ disco.remove_stop_listener = None
+
+ if disco.remove_config_flow_listener is not None:
+ disco.remove_config_flow_listener()
+ disco.remove_config_flow_listener = None
+
+ disco.async_cancel_idle_stop()
+
await disco.pi_disco.close()
del hass.data[DATA_DISCOVERY_SERVICE]
diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json
index 47fc9b2f9267..da55de678ce9 100644
--- a/homeassistant/components/izone/manifest.json
+++ b/homeassistant/components/izone/manifest.json
@@ -10,5 +10,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pizone"],
- "requirements": ["python-izone==1.2.10"]
+ "requirements": ["python-izone==1.3.4"]
}
diff --git a/homeassistant/components/izone/strings.json b/homeassistant/components/izone/strings.json
index 0716bee80747..42b91a22a357 100644
--- a/homeassistant/components/izone/strings.json
+++ b/homeassistant/components/izone/strings.json
@@ -1,12 +1,22 @@
{
"config": {
"abort": {
- "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "discovery_failed": "Failed to start iZone discovery. Make sure your network is properly configured.",
+ "discovery_started": "iZone discovery has started. Your controllers will appear as discovered devices under Settings \u003e Devices \u0026 services.",
+ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
+ "flow_title": "{name}",
"step": {
"confirm": {
- "description": "Do you want to set up iZone?"
+ "description": "Do you want to set up iZone?\n\nController UID: {controller_uid}\nController IP: {host}"
+ },
+ "select_controller": {
+ "data": {
+ "selected_controller_uid": "Controller"
+ },
+ "description": "Multiple unconfigured iZone controllers were found:\n{controllers}\n\nChoose the controller you want to set up now. Any controller you do not select will remain available as a discovered device you can set up later under **Settings** > **Devices & services**."
}
}
},
diff --git a/homeassistant/components/karakeep/__init__.py b/homeassistant/components/karakeep/__init__.py
new file mode 100644
index 000000000000..6710fe6dfc5b
--- /dev/null
+++ b/homeassistant/components/karakeep/__init__.py
@@ -0,0 +1,33 @@
+"""The Karakeep integration."""
+
+from aiokarakeep import KarakeepClient
+
+from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import PLATFORMS
+from .coordinator import KarakeepConfigEntry, KarakeepDataUpdateCoordinator
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: KarakeepConfigEntry) -> bool:
+ """Set up Karakeep from a config entry."""
+ client = KarakeepClient(
+ entry.data[CONF_URL],
+ entry.data[CONF_TOKEN],
+ async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]),
+ )
+ coordinator = KarakeepDataUpdateCoordinator(hass, entry, client)
+
+ 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: KarakeepConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/karakeep/config_flow.py b/homeassistant/components/karakeep/config_flow.py
new file mode 100644
index 000000000000..b1413a4c0858
--- /dev/null
+++ b/homeassistant/components/karakeep/config_flow.py
@@ -0,0 +1,104 @@
+"""Config flow for Karakeep."""
+
+import logging
+from typing import Any, override
+
+from aiokarakeep import (
+ KarakeepApiError,
+ KarakeepAuthError,
+ KarakeepClient,
+ KarakeepConnectionError,
+ KarakeepInvalidResponseError,
+)
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_URL): str,
+ vol.Required(CONF_TOKEN): str,
+ vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+ }
+)
+
+
+class KarakeepConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Karakeep."""
+
+ VERSION = 1
+
+ async def _async_validate_input(
+ self, url: str, token: str, verify_ssl: bool
+ ) -> dict[str, str]:
+ """Validate the user input allows us to connect."""
+ errors: dict[str, str] = {}
+
+ session = async_get_clientsession(self.hass, verify_ssl)
+ client = KarakeepClient(url, token, session)
+
+ try:
+ await client.async_get_stats()
+ except KarakeepAuthError:
+ errors["base"] = "invalid_auth"
+ except KarakeepConnectionError:
+ errors["base"] = "cannot_connect"
+ except KarakeepApiError, KarakeepInvalidResponseError:
+ errors["base"] = "api_error"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ return errors
+
+ @override
+ 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 = _normalize_url(user_input[CONF_URL])
+ token = user_input[CONF_TOKEN].strip()
+ verify_ssl = user_input[CONF_VERIFY_SSL]
+
+ if url is None:
+ errors["base"] = "invalid_url_format"
+ else:
+ self._async_abort_entries_match({CONF_URL: url})
+
+ errors = await self._async_validate_input(url, token, verify_ssl)
+ if not errors:
+ return self.async_create_entry(
+ title="Karakeep",
+ data={
+ CONF_URL: url,
+ CONF_TOKEN: token,
+ CONF_VERIFY_SSL: verify_ssl,
+ },
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ )
+
+
+def _normalize_url(raw_url: str) -> str | None:
+ """Return the normalized base URL, or None if it is not a valid URL."""
+ try:
+ parsed_url = URL(raw_url.strip())
+ except ValueError:
+ return None
+ if parsed_url.scheme not in ("http", "https") or not parsed_url.host:
+ return None
+ return str(parsed_url).rstrip("/")
diff --git a/homeassistant/components/karakeep/const.py b/homeassistant/components/karakeep/const.py
new file mode 100644
index 000000000000..b2c5fe1c0604
--- /dev/null
+++ b/homeassistant/components/karakeep/const.py
@@ -0,0 +1,12 @@
+"""Constants for the Karakeep integration."""
+
+from datetime import timedelta
+
+from homeassistant.const import Platform
+
+DOMAIN = "karakeep"
+
+DEFAULT_VERIFY_SSL = True
+UPDATE_INTERVAL = timedelta(seconds=300)
+
+PLATFORMS = [Platform.SENSOR]
diff --git a/homeassistant/components/karakeep/coordinator.py b/homeassistant/components/karakeep/coordinator.py
new file mode 100644
index 000000000000..01033def739f
--- /dev/null
+++ b/homeassistant/components/karakeep/coordinator.py
@@ -0,0 +1,67 @@
+"""Data update coordinator for the Karakeep integration."""
+
+import logging
+from typing import override
+
+from aiokarakeep import (
+ KarakeepApiError,
+ KarakeepAuthError,
+ KarakeepClient,
+ KarakeepConnectionError,
+ KarakeepInvalidResponseError,
+ KarakeepStats,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN, UPDATE_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
+
+type KarakeepConfigEntry = ConfigEntry[KarakeepDataUpdateCoordinator]
+
+
+class KarakeepDataUpdateCoordinator(DataUpdateCoordinator[KarakeepStats]):
+ """Class to manage fetching Karakeep data."""
+
+ config_entry: KarakeepConfigEntry
+ version: str | None = None
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: KarakeepConfigEntry,
+ client: KarakeepClient,
+ ) -> None:
+ """Initialize the coordinator."""
+ self.client = client
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=UPDATE_INTERVAL,
+ config_entry=entry,
+ )
+
+ @override
+ async def _async_setup(self) -> None:
+ """Fetch the server version once during setup."""
+ try:
+ self.version = await self.client.async_get_version()
+ except (KarakeepApiError, KarakeepConnectionError) as err:
+ raise UpdateFailed(f"Error communicating with Karakeep: {err}") from err
+
+ @override
+ async def _async_update_data(self) -> KarakeepStats:
+ """Fetch data from Karakeep API."""
+ try:
+ return await self.client.async_get_stats()
+ except KarakeepAuthError as err:
+ raise UpdateFailed("Invalid Karakeep API token") from err
+ except KarakeepConnectionError as err:
+ raise UpdateFailed(f"Error communicating with Karakeep: {err}") from err
+ except (KarakeepApiError, KarakeepInvalidResponseError) as err:
+ raise UpdateFailed(f"Invalid response from Karakeep: {err}") from err
diff --git a/homeassistant/components/karakeep/entity.py b/homeassistant/components/karakeep/entity.py
new file mode 100644
index 000000000000..347468f460be
--- /dev/null
+++ b/homeassistant/components/karakeep/entity.py
@@ -0,0 +1,26 @@
+"""Base entity for the Karakeep integration."""
+
+from homeassistant.const import CONF_URL
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import KarakeepDataUpdateCoordinator
+
+
+class KarakeepEntity(CoordinatorEntity[KarakeepDataUpdateCoordinator]):
+ """Base class for Karakeep entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: KarakeepDataUpdateCoordinator) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ url = coordinator.config_entry.data[CONF_URL]
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
+ manufacturer="Karakeep",
+ entry_type=DeviceEntryType.SERVICE,
+ configuration_url=url,
+ sw_version=coordinator.version,
+ )
diff --git a/homeassistant/components/karakeep/icons.json b/homeassistant/components/karakeep/icons.json
new file mode 100644
index 000000000000..85ffe948b51f
--- /dev/null
+++ b/homeassistant/components/karakeep/icons.json
@@ -0,0 +1,24 @@
+{
+ "entity": {
+ "sensor": {
+ "archived": {
+ "default": "mdi:archive"
+ },
+ "bookmarks": {
+ "default": "mdi:bookmark"
+ },
+ "favorites": {
+ "default": "mdi:star"
+ },
+ "highlights": {
+ "default": "mdi:marker"
+ },
+ "lists": {
+ "default": "mdi:format-list-bulleted"
+ },
+ "tags": {
+ "default": "mdi:tag"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/karakeep/manifest.json b/homeassistant/components/karakeep/manifest.json
new file mode 100644
index 000000000000..7561436b01b6
--- /dev/null
+++ b/homeassistant/components/karakeep/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "karakeep",
+ "name": "Karakeep",
+ "codeowners": ["@sli-cka"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/karakeep",
+ "integration_type": "service",
+ "iot_class": "local_polling",
+ "quality_scale": "bronze",
+ "requirements": ["aiokarakeep==0.3.0"]
+}
diff --git a/homeassistant/components/karakeep/quality_scale.yaml b/homeassistant/components/karakeep/quality_scale.yaml
new file mode 100644
index 000000000000..4df22ff58d89
--- /dev/null
+++ b/homeassistant/components/karakeep/quality_scale.yaml
@@ -0,0 +1,84 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: This integration does not have custom service 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 have custom service actions.
+ docs-conditions:
+ status: exempt
+ comment: This integration does not have any conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: This integration does not have any triggers.
+ entity-event-setup:
+ status: exempt
+ comment: Entities do not subscribe to external 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: This integration does not have custom service actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: This integration has no options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: todo
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: This integration does not use discovery.
+ 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: Karakeep exposes one service device per config entry.
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations:
+ status: exempt
+ comment: This integration does not raise translatable Home Assistant exceptions.
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices:
+ status: exempt
+ comment: Karakeep exposes one service device per config entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/karakeep/sensor.py b/homeassistant/components/karakeep/sensor.py
new file mode 100644
index 000000000000..88013d5f36a3
--- /dev/null
+++ b/homeassistant/components/karakeep/sensor.py
@@ -0,0 +1,100 @@
+"""Sensor platform for Karakeep."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import override
+
+from aiokarakeep import KarakeepStats
+
+from homeassistant.components.sensor import (
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import KarakeepConfigEntry
+from .entity import KarakeepEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class KarakeepSensorEntityDescription(SensorEntityDescription):
+ """Describes a Karakeep sensor."""
+
+ value_fn: Callable[[KarakeepStats], int]
+
+
+SENSOR_DESCRIPTIONS: tuple[KarakeepSensorEntityDescription, ...] = (
+ KarakeepSensorEntityDescription(
+ key="bookmarks",
+ translation_key="bookmarks",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda stats: stats.num_bookmarks,
+ ),
+ KarakeepSensorEntityDescription(
+ key="favorites",
+ translation_key="favorites",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda stats: stats.num_favorites,
+ ),
+ KarakeepSensorEntityDescription(
+ key="archived",
+ translation_key="archived",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda stats: stats.num_archived,
+ ),
+ KarakeepSensorEntityDescription(
+ key="highlights",
+ translation_key="highlights",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda stats: stats.num_highlights,
+ ),
+ KarakeepSensorEntityDescription(
+ key="lists",
+ translation_key="lists",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda stats: stats.num_lists,
+ ),
+ KarakeepSensorEntityDescription(
+ key="tags",
+ translation_key="tags",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda stats: stats.num_tags,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: KarakeepConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Karakeep sensors based on a config entry."""
+ async_add_entities(
+ KarakeepStatSensor(entry, description) for description in SENSOR_DESCRIPTIONS
+ )
+
+
+class KarakeepStatSensor(KarakeepEntity, SensorEntity):
+ """Representation of a Karakeep statistic as a sensor entity."""
+
+ entity_description: KarakeepSensorEntityDescription
+
+ def __init__(
+ self,
+ entry: KarakeepConfigEntry,
+ entity_description: KarakeepSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(entry.runtime_data)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{entry.entry_id}_{entity_description.key}"
+
+ @property
+ @override
+ def native_value(self) -> int:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/karakeep/strings.json b/homeassistant/components/karakeep/strings.json
new file mode 100644
index 000000000000..1aceb6c4e076
--- /dev/null
+++ b/homeassistant/components/karakeep/strings.json
@@ -0,0 +1,57 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ },
+ "error": {
+ "api_error": "The Karakeep API returned an unexpected response.",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "invalid_url_format": "Enter a valid URL including http:// or https://.",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "token": "API token",
+ "url": "URL",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "token": "The API token used to connect to your Karakeep instance.",
+ "url": "The URL of your Karakeep instance, including http:// or https://.",
+ "verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates."
+ },
+ "description": "Connect to your Karakeep instance."
+ }
+ }
+ },
+ "entity": {
+ "sensor": {
+ "archived": {
+ "name": "Archived",
+ "unit_of_measurement": "items"
+ },
+ "bookmarks": {
+ "name": "Bookmarks",
+ "unit_of_measurement": "bookmarks"
+ },
+ "favorites": {
+ "name": "Favorites",
+ "unit_of_measurement": "favorites"
+ },
+ "highlights": {
+ "name": "Highlights",
+ "unit_of_measurement": "highlights"
+ },
+ "lists": {
+ "name": "Lists",
+ "unit_of_measurement": "lists"
+ },
+ "tags": {
+ "name": "Tags",
+ "unit_of_measurement": "tags"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py
index 85e43f280e03..9e2d51ab6d8c 100644
--- a/homeassistant/components/kiosker/config_flow.py
+++ b/homeassistant/components/kiosker/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow for the Kiosker integration."""
+from collections.abc import Mapping
import logging
from typing import Any, override
@@ -37,6 +38,11 @@ STEP_ZEROCONF_CONFIRM_DATA_SCHEMA = vol.Schema(
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool,
}
)
+STEP_REAUTH_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_TOKEN): str,
+ }
+)
async def validate_input(
@@ -121,6 +127,43 @@ class KioskerConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle reauth."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reauth confirmation."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+
+ if user_input is not None:
+ config_data = {
+ **reauth_entry.data,
+ CONF_API_TOKEN: user_input[CONF_API_TOKEN],
+ }
+ validation_errors, device_id = await validate_input(self.hass, config_data)
+ if validation_errors:
+ errors.update(validation_errors)
+ else:
+ assert device_id is not None
+ await self.async_set_unique_id(device_id, raise_on_progress=False)
+ self._abort_if_unique_id_mismatch(reason="wrong_device")
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]},
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=STEP_REAUTH_DATA_SCHEMA,
+ description_placeholders={"name": reauth_entry.title},
+ errors=errors,
+ )
+
@override
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json
index fc8c2ed911fa..7256cda13b22 100644
--- a/homeassistant/components/kiosker/manifest.json
+++ b/homeassistant/components/kiosker/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/kiosker",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "bronze",
+ "quality_scale": "silver",
"requirements": ["kiosker-python-api==1.2.9"],
"zeroconf": ["_kiosker._tcp.local."]
}
diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml
index 6b4e2e01cefb..cd3cf4e2bb63 100644
--- a/homeassistant/components/kiosker/quality_scale.yaml
+++ b/homeassistant/components/kiosker/quality_scale.yaml
@@ -34,11 +34,12 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
- reauthentication-flow: todo
+ reauthentication-flow: done
test-coverage: done
# Gold
devices: done
+ diagnostics: todo
discovery-update-info: todo
discovery: done
docs-data-update: done
diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json
index 2e700a3a43eb..ea117b290b8a 100644
--- a/homeassistant/components/kiosker/strings.json
+++ b/homeassistant/components/kiosker/strings.json
@@ -2,7 +2,9 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "wrong_device": "The device does not match the configured device."
},
"error": {
"bad_request": "Invalid request. Check your configuration.",
@@ -13,6 +15,16 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
+ "reauth_confirm": {
+ "data": {
+ "api_token": "[%key:common::config_flow::data::api_token%]"
+ },
+ "data_description": {
+ "api_token": "The API token for the Kiosker App. This can be generated in the app API settings."
+ },
+ "description": "Re-authenticate {name} with Home Assistant. Generate a new API token in the Kiosker app settings.",
+ "title": "Re-authenticate Kiosker"
+ },
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",
diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py
index c264f05f9916..4cd03a8e9653 100644
--- a/homeassistant/components/kiwi/lock.py
+++ b/homeassistant/components/kiwi/lock.py
@@ -13,10 +13,9 @@ from homeassistant.components.lock import (
)
from homeassistant.const import (
ATTR_ID,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_PASSWORD,
CONF_USERNAME,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -68,12 +67,8 @@ class KiwiLock(LockEntity):
self._state = LockState.LOCKED
address = kiwi_lock.get("address")
- address.update(
- {
- ATTR_LATITUDE: address.pop("lat", None),
- ATTR_LONGITUDE: address.pop("lng", None),
- }
- )
+ latitude = address.pop("lat", None)
+ longitude = address.pop("lng", None)
self._device_attrs = {
ATTR_ID: self.lock_id,
@@ -81,6 +76,8 @@ class KiwiLock(LockEntity):
ATTR_PERMISSION: kiwi_lock.get("highest_permission"),
ATTR_CAN_INVITE: kiwi_lock.get("can_invite"),
**address,
+ EntityStateAttribute.LATITUDE: latitude,
+ EntityStateAttribute.LONGITUDE: longitude,
}
@property
diff --git a/homeassistant/components/klik_aan_klik_uit/strings.json b/homeassistant/components/klik_aan_klik_uit/strings.json
index 14c367c6cfce..e9648dba6381 100644
--- a/homeassistant/components/klik_aan_klik_uit/strings.json
+++ b/homeassistant/components/klik_aan_klik_uit/strings.json
@@ -8,7 +8,7 @@
"error": {},
"step": {
"pairing_mode": {
- "description": "Bring device into learn mode by pushing it's button for more than 2 seconds, then press Ok.",
+ "description": "Bring the device into learn mode by pushing its button for more than 2 seconds, then press OK.",
"title": "Pair device"
},
"pairing_result": {
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index a1ec1e84547f..6dff1f12f512 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -24,11 +24,13 @@ from .const import (
CONF_KNX_KNXKEY_FILENAME,
CONF_KNX_RATE_LIMIT,
CONF_KNX_STATE_UPDATER,
+ CONF_KNX_TELEGRAM_DB_BACKEND,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
DATA_HASS_CONFIG,
DOMAIN,
KNX_MODULE_KEY,
+ KNX_TELEGRAM_BACKEND_SQLITE,
KNX_TELEGRAM_DB_PATH_SQLITE,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
@@ -165,10 +167,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
- if entry.version > 2:
- # Don't migrate from future version
- return False
-
if entry.version == 1:
new_data = {**entry.data}
new_options = {**entry.options}
@@ -192,11 +190,23 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
new_options.setdefault(CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER)
new_options.setdefault(CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT)
+ new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE
+
hass.config_entries.async_update_entry(
- entry, data=new_data, options=new_options, version=2
+ entry, data=new_data, options=new_options, version=2, minor_version=2
)
_LOGGER.info("Migration to version 2 successful")
+ if entry.version == 2 and entry.minor_version < 2:
+ # version 2.2 introduced in 2026.8
+ new_options = {**entry.options}
+ if CONF_KNX_TELEGRAM_DB_BACKEND not in new_options:
+ new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE
+ hass.config_entries.async_update_entry(
+ entry, options=new_options, minor_version=2
+ )
+ _LOGGER.info("Migration to version 2.2 successful")
+
return True
diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py
index 718c76aca29e..f652725d5152 100644
--- a/homeassistant/components/knx/button.py
+++ b/homeassistant/components/knx/button.py
@@ -1,19 +1,24 @@
"""Support for KNX button entities."""
-from typing import override
+from typing import Any, override
-from xknx.devices import RawValue as XknxRawValue
+from xknx.devices import ExposeSensor as XknxExposeSensor, RawValue as XknxRawValue
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 AddConfigEntryEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ async_get_current_platform,
+)
from homeassistant.helpers.typing import ConfigType
-from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY
-from .entity import KnxYamlEntity
+from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY
+from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .knx_module import KNXModule
+from .storage.const import CONF_DATA, CONF_ENTITY, CONF_GA_SEND
+from .storage.util import ConfigExtractor
async def async_setup_entry(
@@ -21,27 +26,60 @@ async def async_setup_entry(
config_entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Set up the KNX binary sensor platform."""
+ """Set up button(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
- config: list[ConfigType] = knx_module.config_yaml[Platform.BUTTON]
+ platform = async_get_current_platform()
+ knx_module.config_store.add_platform(
+ platform=Platform.BUTTON,
+ controller=KnxUiEntityPlatformController(
+ knx_module=knx_module,
+ entity_platform=platform,
+ entity_class=KnxUiButton,
+ ),
+ )
- async_add_entities(KNXButton(knx_module, entity_config) for entity_config in config)
+ entities: list[KnxYamlEntity | KnxUiEntity] = []
+ if yaml_platform_config := knx_module.config_yaml.get(Platform.BUTTON):
+ entities.extend(
+ KnxYamlButton(knx_module, entity_config)
+ for entity_config in yaml_platform_config
+ )
+ if ui_config := knx_module.config_store.data["entities"].get(Platform.BUTTON):
+ entities.extend(
+ KnxUiButton(knx_module, unique_id, config)
+ for unique_id, config in ui_config.items()
+ )
+ if entities:
+ async_add_entities(entities)
-class KNXButton(KnxYamlEntity, ButtonEntity):
+class _KnxButton(ButtonEntity):
"""Representation of a KNX button."""
+ _device: XknxRawValue | XknxExposeSensor
+ _payload: Any
+
+ @override
+ async def async_press(self) -> None:
+ """Press the button."""
+ await self._device.set(self._payload)
+
+
+class KnxYamlButton(_KnxButton, KnxYamlEntity):
+ """Representation of a KNX button configured via YAML."""
+
_device: XknxRawValue
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX button."""
+ # dpt-value to payload conversion is done in schema validation for yaml config
+ self._payload = config[CONF_PAYLOAD]
self._device = XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
)
- self._payload = config[CONF_PAYLOAD]
super().__init__(
knx_module=knx_module,
unique_id=f"{self._device.remote_value.group_address}_{self._payload}",
@@ -49,7 +87,39 @@ class KNXButton(KnxYamlEntity, ButtonEntity):
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
- @override
- async def async_press(self) -> None:
- """Press the button."""
- await self._device.set(self._payload)
+
+class KnxUiButton(_KnxButton, KnxUiEntity):
+ """Representation of a KNX button configured via the UI."""
+
+ _device: XknxRawValue | XknxExposeSensor
+
+ def __init__(
+ self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
+ ) -> None:
+ """Initialize a KNX button."""
+ knx_conf = ConfigExtractor(config[DOMAIN])
+ button_data = knx_conf.get(CONF_DATA)
+ if CONF_PAYLOAD in button_data and CONF_PAYLOAD_LENGTH in button_data:
+ self._payload = int(button_data[CONF_PAYLOAD], 16)
+ self._device = XknxRawValue(
+ xknx=knx_module.xknx,
+ name=config[CONF_ENTITY][CONF_NAME],
+ payload_length=button_data[CONF_PAYLOAD_LENGTH],
+ group_address=knx_conf.get_write(CONF_GA_SEND),
+ )
+ else:
+ dpt_string = knx_conf.get_dpt(CONF_GA_SEND)
+ self._payload = button_data[CONF_VALUE]
+ self._device = XknxExposeSensor(
+ xknx=knx_module.xknx,
+ name=config[CONF_ENTITY][CONF_NAME],
+ value_type=dpt_string,
+ group_address=knx_conf.get_write(CONF_GA_SEND),
+ respond_to_read=False,
+ )
+
+ super().__init__(
+ knx_module=knx_module,
+ unique_id=unique_id,
+ entity_config=config[CONF_ENTITY],
+ )
diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py
index 50a2c7206b44..c612f26714d4 100644
--- a/homeassistant/components/knx/config_flow.py
+++ b/homeassistant/components/knx/config_flow.py
@@ -1,8 +1,12 @@
"""Config flow for KNX."""
+import asyncio
from collections.abc import AsyncGenerator
from typing import Any, Final, Literal, override
+from urllib.parse import quote, unquote, urlparse, urlunparse
+from knx_telegram_store import ConnectionErrorKind
+from knx_telegram_store.backends.postgres import PostgresStore
import voluptuous as vol
from xknx import XKNX
from xknx.exceptions.exception import (
@@ -49,8 +53,16 @@ from .const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
+ CONF_KNX_TELEGRAM_DB_BACKEND,
+ CONF_KNX_TELEGRAM_DB_DATABASE,
+ CONF_KNX_TELEGRAM_DB_HOST,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
+ CONF_KNX_TELEGRAM_DB_PASSWORD,
+ CONF_KNX_TELEGRAM_DB_PORT,
+ CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
+ CONF_KNX_TELEGRAM_DB_TLS,
+ CONF_KNX_TELEGRAM_DB_USER,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
@@ -58,6 +70,8 @@ from .const import (
DEFAULT_ROUTING_IA,
DOMAIN,
KNX_MODULE_KEY,
+ KNX_TELEGRAM_BACKEND_POSTGRES,
+ KNX_TELEGRAM_BACKEND_SQLITE,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
KNXConfigEntryData,
@@ -82,12 +96,17 @@ DEFAULT_ENTRY_OPTIONS = KNXConfigEntryOptions(
state_updater=CONF_KNX_DEFAULT_STATE_UPDATER,
telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT,
telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
+ telegram_db_backend=KNX_TELEGRAM_BACKEND_SQLITE,
)
CONF_KEYRING_FILE: Final = "knxkeys_file"
CONF_KNX_TELEGRAM_STORE_SECTION: Final = "telegram_store_section"
+# Timeout for the PostgreSQL connection check, so an unreachable host cannot
+# block the options flow until the driver/OS connection timeout expires.
+DSN_CHECK_TIMEOUT = 10
+
CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type"
CONF_KNX_TUNNELING_TYPE_LABELS: Final = {
CONF_KNX_TUNNELING: "UDP (Tunneling v1)",
@@ -113,6 +132,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a KNX config flow."""
VERSION = 2
+ MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize KNX config flow."""
@@ -951,6 +971,7 @@ class KNXOptionsFlow(OptionsFlowWithReload):
"""Manage KNX communication settings."""
if user_input is not None:
telegram_store_section = user_input[CONF_KNX_TELEGRAM_STORE_SECTION]
+ backend = telegram_store_section[CONF_KNX_TELEGRAM_DB_BACKEND]
self.new_entry_options |= KNXConfigEntryOptions(
state_updater=user_input[CONF_KNX_STATE_UPDATER],
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
@@ -960,7 +981,10 @@ class KNXOptionsFlow(OptionsFlowWithReload):
telegram_db_retention_days=telegram_store_section[
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS
],
+ telegram_db_backend=backend,
)
+ if backend == KNX_TELEGRAM_BACKEND_POSTGRES:
+ return await self.async_step_telegram_store_postgres()
return self.finish_flow()
data_schema = {
@@ -1020,6 +1044,22 @@ class KNXOptionsFlow(OptionsFlowWithReload):
),
vol.Coerce(int),
),
+ vol.Required(
+ CONF_KNX_TELEGRAM_DB_BACKEND,
+ default=self.initial_options.get(
+ CONF_KNX_TELEGRAM_DB_BACKEND,
+ KNX_TELEGRAM_BACKEND_SQLITE,
+ ),
+ ): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=[
+ KNX_TELEGRAM_BACKEND_SQLITE,
+ KNX_TELEGRAM_BACKEND_POSTGRES,
+ ],
+ mode=selector.SelectSelectorMode.DROPDOWN,
+ translation_key="telegram_backend",
+ )
+ ),
}
),
),
@@ -1027,5 +1067,136 @@ class KNXOptionsFlow(OptionsFlowWithReload):
return self.async_show_form(
step_id="communication_settings",
data_schema=vol.Schema(data_schema),
+ last_step=False,
+ )
+
+ async def async_step_telegram_store_postgres(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Collect and validate the PostgreSQL telegram store connection."""
+ current_dsn = self.initial_options.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "")
+ parsed = _parse_dsn(current_dsn)
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ # Reuse the stored password when the field is left blank.
+ params = {
+ **user_input,
+ CONF_KNX_TELEGRAM_DB_PASSWORD: (
+ user_input.get(CONF_KNX_TELEGRAM_DB_PASSWORD)
+ or parsed.get(CONF_KNX_TELEGRAM_DB_PASSWORD, "")
+ ),
+ }
+ dsn = _build_dsn(params)
+ errors = await _async_check_postgres_dsn(dsn)
+ if not errors:
+ self.new_entry_options |= KNXConfigEntryOptions(
+ telegram_db_postgres_dsn=dsn
+ )
+ return self.finish_flow()
+
+ data_schema = vol.Schema(
+ {
+ vol.Required(
+ CONF_KNX_TELEGRAM_DB_HOST,
+ default=parsed.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost"),
+ ): selector.TextSelector(),
+ vol.Required(
+ CONF_KNX_TELEGRAM_DB_PORT,
+ default=parsed.get(CONF_KNX_TELEGRAM_DB_PORT, 5432),
+ ): vol.All(
+ selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ min=1,
+ max=65535,
+ mode=selector.NumberSelectorMode.BOX,
+ )
+ ),
+ vol.Coerce(int),
+ ),
+ vol.Required(
+ CONF_KNX_TELEGRAM_DB_USER,
+ default=parsed.get(CONF_KNX_TELEGRAM_DB_USER, ""),
+ ): selector.TextSelector(),
+ vol.Required(
+ CONF_KNX_TELEGRAM_DB_PASSWORD, default=""
+ ): selector.TextSelector(
+ selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
+ ),
+ vol.Required(
+ CONF_KNX_TELEGRAM_DB_DATABASE,
+ default=parsed.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"),
+ ): selector.TextSelector(),
+ vol.Required(
+ CONF_KNX_TELEGRAM_DB_TLS,
+ default=parsed.get(CONF_KNX_TELEGRAM_DB_TLS, False),
+ ): selector.BooleanSelector(),
+ }
+ )
+ if user_input is not None:
+ data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
+ return self.async_show_form(
+ step_id="telegram_store_postgres",
+ data_schema=data_schema,
+ errors=errors,
last_step=True,
)
+
+
+async def _async_check_postgres_dsn(dsn: str) -> dict[str, str]:
+ """Validate a PostgreSQL DSN, returning form errors on failure."""
+ connection_errors = {
+ ConnectionErrorKind.AUTH: "invalid_auth",
+ ConnectionErrorKind.HOST_UNREACHABLE: "host_unreachable",
+ ConnectionErrorKind.DATABASE_MISSING: "database_missing",
+ ConnectionErrorKind.PERMISSION: "permission",
+ ConnectionErrorKind.TIMEOUT: "timeout",
+ ConnectionErrorKind.MISSING_DEPENDENCY: "missing_dependency",
+ }
+ try:
+ async with asyncio.timeout(DSN_CHECK_TIMEOUT):
+ check_result = await PostgresStore.check_config(dsn)
+ except TimeoutError:
+ return {"base": "timeout"}
+ except ValueError:
+ return {"base": "cannot_connect"}
+ if not check_result.ok:
+ return {"base": connection_errors.get(check_result.kind, "cannot_connect")}
+ return {}
+
+
+def _build_dsn(params: dict[str, Any]) -> str:
+ """Build a PostgreSQL DSN from form params."""
+ quoted_user = quote(params.get(CONF_KNX_TELEGRAM_DB_USER, ""), safe="")
+ quoted_password = quote(params.get(CONF_KNX_TELEGRAM_DB_PASSWORD, ""), safe="")
+ host = params.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost")
+ if ":" in host and not host.startswith("["):
+ # IPv6 literals must be bracketed in the URL netloc
+ host = f"[{host}]"
+ port = int(params.get(CONF_KNX_TELEGRAM_DB_PORT, 5432))
+ quoted_database = quote(
+ params.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), safe=""
+ )
+ tls = params.get(CONF_KNX_TELEGRAM_DB_TLS, False)
+
+ netloc = f"{quoted_user}:{quoted_password}@{host}:{port}"
+ query = "sslmode=require" if tls else ""
+ return urlunparse(("postgresql", netloc, f"/{quoted_database}", "", query, ""))
+
+
+def _parse_dsn(dsn: str) -> dict[str, Any]:
+ """Parse a PostgreSQL DSN into form params."""
+ if not dsn:
+ return {}
+ try:
+ url = urlparse(dsn)
+ return {
+ CONF_KNX_TELEGRAM_DB_USER: unquote(url.username or ""),
+ CONF_KNX_TELEGRAM_DB_PASSWORD: unquote(url.password or ""),
+ CONF_KNX_TELEGRAM_DB_HOST: url.hostname or "localhost",
+ CONF_KNX_TELEGRAM_DB_PORT: url.port or 5432,
+ CONF_KNX_TELEGRAM_DB_DATABASE: unquote(url.path.lstrip("/")),
+ CONF_KNX_TELEGRAM_DB_TLS: "sslmode=require" in url.query,
+ }
+ except ValueError, AttributeError:
+ return {}
diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py
index b12d6241bb50..f1c203d18a33 100644
--- a/homeassistant/components/knx/const.py
+++ b/homeassistant/components/knx/const.py
@@ -26,6 +26,7 @@ KNX_ADDRESS: Final = "address"
CONF_INVERT: Final = "invert"
CONF_KNX_EXPOSE: Final = "expose"
CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address"
+CONF_VALUE: Final = "value"
##
# Connection constants
@@ -52,8 +53,20 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0
DEFAULT_ROUTING_IA: Final = "0.0.240"
+CONF_KNX_TELEGRAM_DB_BACKEND: Final = "telegram_db_backend"
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: Final = "telegram_db_retention_days"
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: Final = "telegram_db_load_hours"
+CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: Final = "telegram_db_postgres_dsn"
+
+CONF_KNX_TELEGRAM_DB_HOST: Final = "host"
+CONF_KNX_TELEGRAM_DB_PORT: Final = "port"
+CONF_KNX_TELEGRAM_DB_USER: Final = "user"
+CONF_KNX_TELEGRAM_DB_PASSWORD: Final = "password"
+CONF_KNX_TELEGRAM_DB_DATABASE: Final = "database"
+CONF_KNX_TELEGRAM_DB_TLS: Final = "tls"
+
+KNX_TELEGRAM_BACKEND_SQLITE: Final = "sqlite"
+KNX_TELEGRAM_BACKEND_POSTGRES: Final = "postgres"
KNX_TELEGRAM_DB_RETENTION_DEFAULT: Final = 10 # days
KNX_TELEGRAM_LOAD_HOURS_DEFAULT: Final = 24 # 1 day
@@ -138,6 +151,8 @@ class KNXConfigEntryOptions(TypedDict, total=False):
# Integration only (not forwarded to xknx)
telegram_db_retention_days: int
telegram_db_load_hours: int
+ telegram_db_backend: str # sqlite | postgres
+ telegram_db_postgres_dsn: str
class ColorTempModes(Enum):
@@ -178,6 +193,7 @@ SUPPORTED_PLATFORMS_YAML: Final = {
SUPPORTED_PLATFORMS_UI: Final = {
Platform.BINARY_SENSOR,
+ Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.DATE,
diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py
index e84c9f2c7941..ec7c7cb2c220 100644
--- a/homeassistant/components/knx/date.py
+++ b/homeassistant/components/knx/date.py
@@ -79,10 +79,8 @@ class _KNXDate(DateEntity, RestoreEntity):
"""Restore last state."""
await super().async_added_to_hass()
if (
- not self._device.remote_value.readable
- and (last_state := await self.async_get_last_state()) is not None
- and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
- ):
+ last_state := await self.async_get_last_state()
+ ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
self._device.remote_value.value = XKNXDate.from_date(
dt_date.fromisoformat(last_state.state)
)
diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py
index 91c81eba8f15..04674fa4cd28 100644
--- a/homeassistant/components/knx/datetime.py
+++ b/homeassistant/components/knx/datetime.py
@@ -80,10 +80,8 @@ class _KNXDateTime(DateTimeEntity, RestoreEntity):
"""Restore last state."""
await super().async_added_to_hass()
if (
- not self._device.remote_value.readable
- and (last_state := await self.async_get_last_state()) is not None
- and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
- ):
+ last_state := await self.async_get_last_state()
+ ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
self._device.remote_value.value = XKNXDateTime.from_datetime(
datetime.fromisoformat(last_state.state).astimezone(
dt_util.get_default_time_zone()
diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py
index c685a5123b0c..d637eb551888 100644
--- a/homeassistant/components/knx/diagnostics.py
+++ b/homeassistant/components/knx/diagnostics.py
@@ -15,6 +15,7 @@ from .const import (
CONF_KNX_ROUTING_BACKBONE_KEY,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION,
CONF_KNX_SECURE_USER_PASSWORD,
+ CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
DOMAIN,
KNX_MODULE_KEY,
)
@@ -24,6 +25,7 @@ TO_REDACT = {
CONF_KNX_KNXKEY_PASSWORD,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION,
+ CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
}
diff --git a/homeassistant/components/knx/dpt.py b/homeassistant/components/knx/dpt.py
index bb5792c00616..2ab86f0626a3 100644
--- a/homeassistant/components/knx/dpt.py
+++ b/homeassistant/components/knx/dpt.py
@@ -2,9 +2,9 @@
from collections.abc import Mapping
from functools import cache
-from typing import Literal, TypedDict
+from typing import Literal, NotRequired, TypedDict, cast
-from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric
+from xknx.dpt import DPTBase, DPTComplex, DPTComplexFieldSchema, DPTEnum, DPTNumeric
from xknx.dpt.dpt_16 import DPTString
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
@@ -24,15 +24,28 @@ class DPTInfo(TypedDict):
sensor_device_class: SensorDeviceClass | None
sensor_state_class: SensorStateClass | None
+ payload_length: int
+
+ # numeric specific
+ min: NotRequired[float]
+ max: NotRequired[float]
+ step: NotRequired[float]
+
+ # enum specific
+ options: NotRequired[list[str]]
+
+ # complex specific
+ schema: NotRequired[list[DPTComplexFieldSchema]]
+
@cache
def get_supported_dpts() -> Mapping[str, DPTInfo]:
"""Return a mapping of supported DPTs with HA specific attributes."""
- dpts = {}
+ dpts: dict[str, DPTInfo] = {}
for dpt_class in DPTBase.dpt_class_tree():
dpt_number_str = dpt_class.dpt_number_str()
ha_dpt_class = _ha_dpt_class(dpt_class)
- dpts[dpt_number_str] = DPTInfo(
+ info = DPTInfo(
dpt_class=ha_dpt_class,
main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests
sub=dpt_class.dpt_sub_number,
@@ -40,7 +53,15 @@ def get_supported_dpts() -> Mapping[str, DPTInfo]:
unit=_sensor_unit_overrides.get(dpt_number_str, dpt_class.unit),
sensor_device_class=_sensor_device_classes.get(dpt_number_str),
sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str),
+ payload_length=dpt_class.payload_length,
)
+ if ha_dpt_class == "numeric":
+ _add_numeric_details(info, cast(type[DPTNumeric], dpt_class))
+ elif ha_dpt_class == "enum":
+ _add_enum_details(info, cast(type[DPTEnum], dpt_class))
+ elif ha_dpt_class == "complex":
+ _add_complex_details(info, cast(type[DPTComplex], dpt_class))
+ dpts[dpt_number_str] = info
return dpts
@@ -57,6 +78,23 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass:
raise ValueError("Unsupported DPT class")
+def _add_numeric_details(dpt_info: DPTInfo, dpt_cls: type[DPTNumeric]) -> None:
+ """Add numeric specific details to the DPTInfo."""
+ dpt_info["min"] = dpt_cls.value_min
+ dpt_info["max"] = dpt_cls.value_max
+ dpt_info["step"] = dpt_cls.resolution
+
+
+def _add_enum_details(dpt_info: DPTInfo, dpt_cls: type[DPTEnum]) -> None:
+ """Add enum specific details to the DPTInfo."""
+ dpt_info["options"] = [o.name.lower() for o in dpt_cls.get_valid_values()]
+
+
+def _add_complex_details(dpt_info: DPTInfo, dpt_cls: type[DPTComplex]) -> None:
+ """Add complex specific details to the DPTInfo."""
+ dpt_info["schema"] = dpt_cls.get_dict_schema()
+
+
_sensor_device_classes: Mapping[str, SensorDeviceClass] = {
"7.011": SensorDeviceClass.DISTANCE,
"7.012": SensorDeviceClass.CURRENT,
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index a67d99cc3c6a..4c372bb876e7 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -14,7 +14,7 @@
"xknx==3.16.0",
"xknxproject==3.9.0",
"knx-frontend==2026.6.23.203726",
- "knx-telegram-store[sqlite]==0.3.2"
+ "knx-telegram-store[sqlite,postgres]==0.10.1"
],
"single_config_entry": true
}
diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py
index db59b9527eb5..b6102c805e86 100644
--- a/homeassistant/components/knx/number.py
+++ b/homeassistant/components/knx/number.py
@@ -82,10 +82,8 @@ class _KnxNumber(RestoreNumber):
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
- if (
- not self._device.sensor_value.readable
- and (last_state := await self.async_get_last_state())
- and (last_number_data := await self.async_get_last_number_data())
+ if (last_state := await self.async_get_last_state()) and (
+ last_number_data := await self.async_get_last_number_data()
):
if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
self._device.sensor_value.value = last_number_data.native_value
diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py
index 0d416c7b7a1b..7c200a77bc37 100644
--- a/homeassistant/components/knx/schema.py
+++ b/homeassistant/components/knx/schema.py
@@ -57,6 +57,7 @@ from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
CONF_SYNC_STATE,
+ CONF_VALUE,
KNX_ADDRESS,
ClimateConf,
ColorTempModes,
@@ -98,9 +99,12 @@ def _max_payload_value(payload_length: int) -> int:
def button_payload_sub_validator(entity_config: OrderedDict) -> OrderedDict:
- """Validate a button entity payload configuration."""
+ """Validate a button entity payload configuration.
+
+ Returns raw payload and length from value and type (DPT), if given.
+ """
if _type := entity_config.get(CONF_TYPE):
- _payload = entity_config[ButtonSchema.CONF_VALUE]
+ _payload = entity_config[CONF_VALUE]
if (transcoder := DPTBase.parse_transcoder(_type)) is None:
raise vol.Invalid(f"'type: {_type}' is not a valid sensor type.")
entity_config[CONF_PAYLOAD_LENGTH] = transcoder.payload_length
@@ -234,8 +238,6 @@ class ButtonSchema(KNXPlatformSchema):
PLATFORM = Platform.BUTTON
- CONF_VALUE = "value"
-
payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`"
length_or_type_msg = (
f"Please use only one of `{CONF_PAYLOAD_LENGTH}` or `{CONF_TYPE}`"
diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py
index f67465291dc5..b9079ac9ee30 100644
--- a/homeassistant/components/knx/select.py
+++ b/homeassistant/components/knx/select.py
@@ -83,9 +83,7 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
- if not self._device.remote_value.readable and (
- last_state := await self.async_get_last_state()
- ):
+ if last_state := await self.async_get_last_state():
if (
last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
and (option := self._option_payloads.get(last_state.state)) is not None
diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py
index d9729684eb2e..530af816b435 100644
--- a/homeassistant/components/knx/storage/const.py
+++ b/homeassistant/components/knx/storage/const.py
@@ -19,6 +19,9 @@ CONF_GA_TIME: Final = "ga_time"
CONF_GA_STEP: Final = "ga_step"
+# Button
+CONF_GA_SEND: Final = "ga_send"
+
# Climate
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py
index 7fffd6baaa0e..26d4c0286908 100644
--- a/homeassistant/components/knx/storage/entity_store_schema.py
+++ b/homeassistant/components/knx/storage/entity_store_schema.py
@@ -3,7 +3,8 @@
from enum import StrEnum, unique
import voluptuous as vol
-from xknx.dpt import DPTNumeric
+from xknx.dpt import DPTBase, DPTBinary, DPTNumeric
+from xknx.exceptions import ConversionError
from homeassistant.components.climate import HVACMode
from homeassistant.components.number import (
@@ -36,9 +37,11 @@ from ..const import (
CONF_CONTEXT_TIMEOUT,
CONF_IGNORE_INTERNAL_STATE,
CONF_INVERT,
+ CONF_PAYLOAD_LENGTH,
CONF_RESET_AFTER,
CONF_RESPOND_TO_READ,
CONF_SYNC_STATE,
+ CONF_VALUE,
DOMAIN,
SUPPORTED_PLATFORMS_UI,
ClimateConf,
@@ -92,6 +95,7 @@ from .const import (
CONF_GA_RED_SWITCH,
CONF_GA_SATURATION,
CONF_GA_SCENE,
+ CONF_GA_SEND,
CONF_GA_SENSOR,
CONF_GA_SETPOINT_SHIFT,
CONF_GA_SPEED,
@@ -115,6 +119,7 @@ from .knx_selector import (
GASelector,
GroupSelect,
GroupSelectOption,
+ KnxPayloadSelector,
KNXSectionFlat,
SyncStateSelector,
)
@@ -169,6 +174,55 @@ BINARY_SENSOR_KNX_SCHEMA = vol.Schema(
},
)
+
+def _button_data_sub_validator(config: dict) -> dict:
+ """Validate data matching configured DPT."""
+ dpt = config[CONF_GA_SEND].get(CONF_DPT)
+ transcoder = None
+ if dpt:
+ transcoder = DPTBase.parse_transcoder(dpt)
+ assert transcoder is not None # already checked by GASelector
+
+ if CONF_VALUE in config[CONF_DATA]:
+ try:
+ transcoder.to_knx(config[CONF_DATA][CONF_VALUE])
+ except ConversionError as ex:
+ raise vol.Invalid(
+ f"Value invalid for DPT {transcoder.dpt_number_str()}",
+ path=([CONF_DATA]),
+ ) from ex
+ elif CONF_PAYLOAD_LENGTH in config[CONF_DATA]:
+ length = config[CONF_DATA][CONF_PAYLOAD_LENGTH]
+ if length != transcoder.payload_length or (
+ length != 0 and transcoder.payload_type is DPTBinary
+ ):
+ raise vol.Invalid(
+ f"Payload length invalid for DPT {transcoder.dpt_number_str()}",
+ path=([CONF_DATA]),
+ )
+ return config
+ # without DPT only raw allowed -> payload + payload_length (checked by KnxPayloadSelector)
+ if CONF_PAYLOAD_LENGTH in config[CONF_DATA]:
+ return config
+ raise vol.Invalid("Invalid configuration for button entity")
+
+
+BUTTON_KNX_SCHEMA = AllSerializeFirst(
+ vol.Schema(
+ {
+ vol.Required(CONF_GA_SEND): GASelector(
+ state=False,
+ write_required=True,
+ passive=False,
+ dpt=["numeric", "enum", "complex", "string"],
+ dpt_required=False, # for raw payload support
+ ),
+ vol.Required(CONF_DATA): KnxPayloadSelector(ga_path=CONF_GA_SEND),
+ },
+ ),
+ _button_data_sub_validator,
+)
+
COVER_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
@@ -741,6 +795,7 @@ SENSOR_KNX_SCHEMA = AllSerializeFirst(
KNX_SCHEMA_FOR_PLATFORM = {
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
+ Platform.BUTTON: BUTTON_KNX_SCHEMA,
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
Platform.COVER: COVER_KNX_SCHEMA,
Platform.DATE: DATE_KNX_SCHEMA,
diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py
index 9bc0a1cd382c..b216752a58ba 100644
--- a/homeassistant/components/knx/storage/knx_selector.py
+++ b/homeassistant/components/knx/storage/knx_selector.py
@@ -6,6 +6,9 @@ from typing import Any, override
import voluptuous as vol
+from homeassistant.const import CONF_PAYLOAD
+
+from ..const import CONF_PAYLOAD_LENGTH, CONF_VALUE
from ..dpt import HaDptClass, get_supported_dpts
from ..validation import ga_validator, maybe_ga_validator, sync_state_validator
from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE
@@ -159,7 +162,11 @@ class GroupSelect(KNXSelectorBase):
class GASelector(KNXSelectorBase):
- """Selector for a KNX group address structure."""
+ """Selector for a KNX group address structure.
+
+ `dpt_required` optional dpt only apply to dpt-class lists, enums are always required.
+ `valid_dpt` is used in frontend to filter dropdown menu - no validation is done.
+ """
selector_type = "knx_group_address"
@@ -171,6 +178,7 @@ class GASelector(KNXSelectorBase):
write_required: bool = False,
state_required: bool = False,
dpt: type[Enum] | list[HaDptClass] | None = None,
+ dpt_required: bool = True,
valid_dpt: str | Iterable[str] | None = None,
) -> None:
"""Initialize the group address selector."""
@@ -180,7 +188,7 @@ class GASelector(KNXSelectorBase):
self.write_required = write_required
self.state_required = state_required
self.dpt = dpt
- # valid_dpt is used in frontend to filter dropdown menu - no validation is done
+ self.dpt_required = dpt_required
self.valid_dpt = (valid_dpt,) if isinstance(valid_dpt, str) else valid_dpt
self.schema = self.build_schema()
@@ -196,6 +204,7 @@ class GASelector(KNXSelectorBase):
}
if self.dpt is not None:
if isinstance(self.dpt, list):
+ # optional / required is not passed to FE - only validated in BE
options["dptClasses"] = self.dpt
else:
options["dptSelect"] = [
@@ -267,7 +276,8 @@ class GASelector(KNXSelectorBase):
"""Add DPT validator to the schema."""
if self.dpt is not None:
if isinstance(self.dpt, list):
- schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts())
+ marker = vol.Required if self.dpt_required else vol.Optional
+ schema[marker(CONF_DPT)] = vol.In(get_supported_dpts())
else:
schema[vol.Required(CONF_DPT)] = vol.In(
{item.value for item in self.dpt}
@@ -300,3 +310,64 @@ class SyncStateSelector(KNXSelectorBase):
if not self.allow_false and not data:
raise vol.Invalid(f"Sync state cannot be {data}")
return self.schema(data)
+
+
+class KnxPayloadSelector(KNXSelectorBase):
+ """Selector for KNX payload configuration.
+
+ Raw payloads are stored as hex strings.
+ """
+
+ schema = vol.Any(
+ {
+ vol.Required(CONF_VALUE): object,
+ },
+ {
+ vol.Required(CONF_PAYLOAD): str,
+ vol.Required(CONF_PAYLOAD_LENGTH): vol.All(int, vol.Range(min=0, max=14)),
+ },
+ )
+ selector_type = "knx_payload"
+
+ def __init__(self, ga_path: str) -> None:
+ """Initialize the KNX payload selector."""
+ self.ga_path = ga_path
+
+ @override
+ def serialize(self) -> dict[str, Any]:
+ """Serialize the selector to a dictionary."""
+ return {
+ "type": self.selector_type,
+ "ga_path": self.ga_path,
+ }
+
+ @override
+ def __call__(self, data: Any) -> Any:
+ """Validate the passed data."""
+ validated = self.schema(data)
+ if CONF_PAYLOAD in validated and CONF_PAYLOAD_LENGTH in validated:
+ payload = validated[CONF_PAYLOAD]
+ payload_length = validated[CONF_PAYLOAD_LENGTH]
+ try:
+ int_payload = int(payload, 16)
+ except ValueError as ex:
+ raise vol.Invalid(f"Invalid payload format: {payload}") from ex
+ validated[CONF_PAYLOAD] = hex(int_payload) # prepends "0x" if not present
+
+ if int_payload < 0:
+ raise vol.Invalid(f"Payload cannot be negative: {payload}")
+ if payload_length == 0:
+ # DPT 1,2,3 is marked length 0, has 6 bit size
+ if int_payload > 63:
+ raise vol.Invalid(
+ f"Payload exceeds DPT 1,2,3 limit of 0x3f (63): {payload}"
+ )
+ else:
+ max_payload = (1 << (payload_length * 8)) - 1
+ if int_payload > max_payload:
+ raise vol.Invalid(
+ f"Payload {payload} exceeds possible maximum for "
+ f"length {payload_length}: {hex(max_payload)}"
+ )
+ # CONF_VALUE branch needs subvalidator as we don't have the DPT available here
+ return validated
diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json
index b5af5bee9836..ddc1f06465c7 100644
--- a/homeassistant/components/knx/strings.json
+++ b/homeassistant/components/knx/strings.json
@@ -453,6 +453,19 @@
}
}
},
+ "button": {
+ "description": "Entity for sending predefined values.",
+ "knx": {
+ "data": {
+ "description": "The value sent when the button is pressed. The format of the value depends on the DPT of the configured address.",
+ "label": "Data"
+ },
+ "ga_send": {
+ "description": "Group address the value is sent to.",
+ "label": "Address"
+ }
+ }
+ },
"climate": {
"description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.",
"knx": {
@@ -1012,8 +1025,32 @@
"title": "Information"
},
"project": {
- "description": "Inspect imported group addresses",
+ "description": "Inspect imported project",
+ "devices": {
+ "channels": "Channels",
+ "group_objects": "Group objects",
+ "lines": "Lines",
+ "locations": "Locations",
+ "not_found": "No devices found in project data.",
+ "title": "Devices"
+ },
+ "group_addresses": {
+ "title": "[%key:component::knx::config_panel::common::group_addresses%]"
+ },
"title": "Project"
+ },
+ "selectors": {
+ "knx-payload-selector": {
+ "dpt_missing": "No DPT selected – Typed mode not available",
+ "mode": {
+ "label": "Payload format",
+ "raw": "Raw payload",
+ "typed": "Typed value"
+ },
+ "raw_length": "Payload length",
+ "raw_length_description": "Length of the raw payload in bytes. For DPT 1, 2 and 3 use `0`.",
+ "raw_payload": "Raw payload"
+ }
}
},
"device_automation": {
@@ -1136,6 +1173,15 @@
}
},
"options": {
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "database_missing": "The specified database does not exist.",
+ "host_unreachable": "Could not reach the database host.",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "missing_dependency": "Required database driver is not installed.",
+ "permission": "Insufficient privileges to access the database.",
+ "timeout": "Connection timed out."
+ },
"step": {
"communication_settings": {
"data": {
@@ -1149,10 +1195,12 @@
"sections": {
"telegram_store_section": {
"data": {
+ "telegram_db_backend": "Telegram storage backend",
"telegram_db_load_hours": "Group monitor history",
"telegram_db_retention_days": "Retention period"
},
"data_description": {
+ "telegram_db_backend": "Select where to store KNX telegram history.",
"telegram_db_load_hours": "Number of hours of telegram history to load when the group monitor is opened.",
"telegram_db_retention_days": "Number of days to keep telegram history. Older telegrams are automatically deleted nightly at 3 AM. Set to `0` to delete all telegram history on every nightly run."
},
@@ -1160,6 +1208,25 @@
}
},
"title": "Communication settings"
+ },
+ "telegram_store_postgres": {
+ "data": {
+ "database": "Database name",
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "port": "[%key:common::config_flow::data::port%]",
+ "tls": "Use TLS",
+ "user": "[%key:common::config_flow::data::username%]"
+ },
+ "data_description": {
+ "database": "Name of the database to store telegrams in.",
+ "host": "Hostname or IP address of the PostgreSQL server.",
+ "password": "Password for the PostgreSQL user. Leave blank to keep the current password.",
+ "port": "Port the PostgreSQL server is listening on.",
+ "tls": "Encrypt the connection to the PostgreSQL server (`sslmode=require`). Note that the server certificate is not verified.",
+ "user": "Username to authenticate with the PostgreSQL server."
+ },
+ "title": "PostgreSQL connection"
}
}
},
@@ -1234,6 +1301,12 @@
"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%]"
}
+ },
+ "telegram_backend": {
+ "options": {
+ "postgres": "PostgreSQL (External)",
+ "sqlite": "Internal storage (Default)"
+ }
}
},
"services": {
diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py
index c7db8212af17..dc9e19b8f341 100644
--- a/homeassistant/components/knx/switch.py
+++ b/homeassistant/components/knx/switch.py
@@ -79,17 +79,15 @@ class _KnxSwitch(SwitchEntity, RestoreEntity):
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
- if not self._device.switch.readable and (
- last_state := await self.async_get_last_state()
- ):
+ if last_state := await self.async_get_last_state():
if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
self._device.switch.value = last_state.state == STATE_ON
@property
@override
- def is_on(self) -> bool:
+ def is_on(self) -> bool | None:
"""Return true if device is on."""
- return bool(self._device.state)
+ return self._device.state
@override
async def async_turn_on(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py
index 3d48589d2451..0e7acb36dfe8 100644
--- a/homeassistant/components/knx/telegrams.py
+++ b/homeassistant/components/knx/telegrams.py
@@ -8,6 +8,7 @@ import os
from typing import Any, TypedDict
from knx_telegram_store import (
+ BufferedPostgresStore,
BufferedSqliteStore,
KnxTelegramStoreException,
StoredTelegram,
@@ -26,7 +27,10 @@ from homeassistant.helpers.storage import STORAGE_DIR, Store
from homeassistant.util import dt as dt_util
from .const import (
+ CONF_KNX_TELEGRAM_DB_BACKEND,
+ CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
+ KNX_TELEGRAM_BACKEND_POSTGRES,
KNX_TELEGRAM_DB_PATH_SQLITE,
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
@@ -48,6 +52,15 @@ EVICT_EXPIRED_HOUR = 3
# at risk from a longer interval are those buffered during an ungraceful shutdown.
FLUSH_INTERVAL_SECONDS = 600
+# The buffer drops the oldest telegrams when full. Size it to cover a full
+# flush interval at ~50 telegrams/s, the maximum rate of a KNX TP line, so
+# nothing is dropped while the database is healthy.
+MAX_BUFFER_TELEGRAMS = FLUSH_INTERVAL_SECONDS * 50
+
+# Timeout for the migration probe and store initialization, so an unreachable
+# database cannot block KNX setup until the driver/OS connection timeout expires.
+STORE_INIT_TIMEOUT = 10
+
class DecodedTelegramPayload(TypedDict):
"""Decoded payload value and metadata."""
@@ -89,19 +102,32 @@ class Telegrams:
self.project = project
self.config = config
+ self.backend: str = config[CONF_KNX_TELEGRAM_DB_BACKEND]
+ self.dsn: str = str(config.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, ""))
self.retention_days: int = config[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS]
- self.store: BufferedSqliteStore | None = None
- self._uninitialized_store: BufferedSqliteStore | None = None
+ self.store: BufferedSqliteStore | BufferedPostgresStore | None = None
+ self._uninitialized_store: (
+ BufferedSqliteStore | BufferedPostgresStore | None
+ ) = None
self._evict_expired_unsub: CALLBACK_TYPE | None = None
- full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE)
- os.makedirs(os.path.dirname(full_path), exist_ok=True)
- self._uninitialized_store = BufferedSqliteStore(
- full_path,
- retention_days=self.retention_days,
- flush_interval=FLUSH_INTERVAL_SECONDS,
- )
+ if self.backend == KNX_TELEGRAM_BACKEND_POSTGRES:
+ self._uninitialized_store = BufferedPostgresStore(
+ self.dsn,
+ retention_days=self.retention_days,
+ flush_interval=FLUSH_INTERVAL_SECONDS,
+ max_buffer_size=MAX_BUFFER_TELEGRAMS,
+ )
+ else:
+ full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE)
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
+ self._uninitialized_store = BufferedSqliteStore(
+ full_path,
+ retention_days=self.retention_days,
+ flush_interval=FLUSH_INTERVAL_SECONDS,
+ max_buffer_size=MAX_BUFFER_TELEGRAMS,
+ )
self._xknx_telegram_cb_handle = (
xknx.telegram_queue.register_telegram_received_cb(
@@ -121,7 +147,8 @@ class Telegrams:
if self._uninitialized_store is None:
return
try:
- needs_migration = await self._uninitialized_store.needs_migration()
+ async with asyncio.timeout(STORE_INIT_TIMEOUT):
+ needs_migration = await self._uninitialized_store.needs_migration()
if needs_migration:
_LOGGER.warning(
"KNX telegram history database schema upgrade/migration is required. "
@@ -129,24 +156,35 @@ class Telegrams:
)
await self._uninitialized_store.initialize()
else:
- _LOGGER.debug("Initializing KNX telegram storage")
- async with asyncio.timeout(10):
+ _LOGGER.debug(
+ "Initializing KNX telegram storage backend '%s'",
+ self.backend,
+ )
+ async with asyncio.timeout(STORE_INIT_TIMEOUT):
await self._uninitialized_store.initialize()
- _LOGGER.info("Successfully initialized KNX telegram storage")
+ _LOGGER.info(
+ "Successfully initialized KNX telegram storage backend '%s'",
+ self.backend,
+ )
except TimeoutError:
- _LOGGER.error("Timeout initializing KNX telegram storage")
+ _LOGGER.error(
+ "Timeout initializing KNX telegram storage backend '%s'",
+ self.backend,
+ )
await self._abort_store_init()
return
except KnxTelegramStoreException as err:
_LOGGER.error(
- "Database error initializing KNX telegram storage: %s",
+ "Database error initializing KNX telegram storage backend '%s': %s",
+ self.backend,
err,
)
await self._abort_store_init()
return
except Exception as err: # noqa: BLE001
_LOGGER.error(
- "Error initializing KNX telegram storage: %s",
+ "Error initializing KNX telegram storage backend '%s': %s",
+ self.backend,
err,
)
await self._abort_store_init()
diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py
index d96c41dc45ac..c42e1863e174 100644
--- a/homeassistant/components/knx/text.py
+++ b/homeassistant/components/knx/text.py
@@ -81,9 +81,7 @@ class _KnxText(TextEntity, RestoreEntity):
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
- if not self._device.remote_value.readable and (
- last_state := await self.async_get_last_state()
- ):
+ if last_state := await self.async_get_last_state():
if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
self._device.remote_value.value = last_state.state
diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py
index 99e16b0a2beb..dd42a23cf397 100644
--- a/homeassistant/components/knx/time.py
+++ b/homeassistant/components/knx/time.py
@@ -79,10 +79,8 @@ class _KNXTime(TimeEntity, RestoreEntity):
"""Restore last state."""
await super().async_added_to_hass()
if (
- not self._device.remote_value.readable
- and (last_state := await self.async_get_last_state()) is not None
- and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
- ):
+ last_state := await self.async_get_last_state()
+ ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
self._device.remote_value.value = XknxTime.from_time(
dt_time.fromisoformat(last_state.state)
)
diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py
index 4a79f7cdd9b0..568de4fe8220 100644
--- a/homeassistant/components/knx/websocket.py
+++ b/homeassistant/components/knx/websocket.py
@@ -8,7 +8,12 @@ import inspect
from typing import TYPE_CHECKING, Any, Final, overload
import knx_frontend as knx_panel
-from knx_telegram_store import KnxTelegramStoreException, TelegramQuery
+from knx_telegram_store import (
+ BufferedPostgresStore,
+ BufferedSqliteStore,
+ KnxTelegramStoreException,
+ TelegramQuery,
+)
import voluptuous as vol
from xknx.telegram import Telegram
from xknxproject.exceptions import XknxProjectException
@@ -200,7 +205,11 @@ def ws_get_base_data(
"connected": knx.xknx.connection_manager.connected.is_set(),
"current_address": str(knx.xknx.current_address),
"telegram_backend": (
- "sqlite" if knx.telegrams.store is not None else "unknown"
+ "sqlite"
+ if isinstance(knx.telegrams.store, BufferedSqliteStore)
+ else "postgres"
+ if isinstance(knx.telegrams.store, BufferedPostgresStore)
+ else "unknown"
),
"telegram_retention": knx.telegrams.store.retention_days
if knx.telegrams.store is not None
diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py
index 3a85a01c3fc8..a072b6edeff3 100644
--- a/homeassistant/components/lamarzocco/config_flow.py
+++ b/homeassistant/components/lamarzocco/config_flow.py
@@ -7,6 +7,7 @@ import uuid
from aiohttp import ClientSession
from pylamarzocco import LaMarzoccoCloudClient
+from pylamarzocco.const import DeviceType
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.models import Thing
from pylamarzocco.util import InstallationKey, generate_installation_key
@@ -105,7 +106,11 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Error connecting to server: %s", exc)
errors["base"] = "cannot_connect"
else:
- self._things = {thing.serial_number: thing for thing in things}
+ self._things = {
+ thing.serial_number: thing
+ for thing in things
+ if thing.type is DeviceType.MACHINE
+ }
if not self._things:
errors["base"] = "no_machines"
diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json
index 15f5f7b76a9a..0abd73db29a1 100644
--- a/homeassistant/components/lamarzocco/manifest.json
+++ b/homeassistant/components/lamarzocco/manifest.json
@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
- "requirements": ["pylamarzocco==2.4.1"]
+ "requirements": ["pylamarzocco==2.4.2"]
}
diff --git a/homeassistant/components/lawn_mower/llm.py b/homeassistant/components/lawn_mower/llm.py
new file mode 100644
index 000000000000..c51d5ecdcbde
--- /dev/null
+++ b/homeassistant/components/lawn_mower/llm.py
@@ -0,0 +1,38 @@
+"""LLM tools for the lawn_mower integration."""
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from .const import DOMAIN
+from .intent import INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING
+
+# Intents owned by this integration that are exposed as LLM tools.
+LLM_INTENTS = (INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING)
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return LLM tools for the integration's intents when its domain is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ if not llm_context.assistant:
+ return None
+
+ if not any(
+ async_should_expose(hass, llm_context.assistant, state.entity_id)
+ for state in hass.states.async_all(DOMAIN)
+ ):
+ return None
+
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type in LLM_INTENTS
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/lcn/device_trigger.py b/homeassistant/components/lcn/device_trigger.py
index 2421fa0b9f5e..799103e71c06 100644
--- a/homeassistant/components/lcn/device_trigger.py
+++ b/homeassistant/components/lcn/device_trigger.py
@@ -1,5 +1,7 @@
"""Provides device triggers for LCN."""
+from typing import Any
+
import voluptuous as vol
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
@@ -75,7 +77,7 @@ async def async_attach_trigger(
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
- event_data = {
+ event_data: dict[str, Any] = {
CONF_DEVICE_ID: config[CONF_DEVICE_ID],
**{
key: config[key]
diff --git a/homeassistant/components/led_infrared/__init__.py b/homeassistant/components/led_infrared/__init__.py
new file mode 100644
index 000000000000..12d4096ea426
--- /dev/null
+++ b/homeassistant/components/led_infrared/__init__.py
@@ -0,0 +1,18 @@
+"""The LED Infrared integration."""
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+PLATFORMS: list[Platform] = [Platform.LIGHT]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up LED Infrared from a config entry."""
+ 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."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/led_infrared/config_flow.py b/homeassistant/components/led_infrared/config_flow.py
new file mode 100644
index 000000000000..dc5998297723
--- /dev/null
+++ b/homeassistant/components/led_infrared/config_flow.py
@@ -0,0 +1,139 @@
+"""Config flow for the LED Infrared integration."""
+
+from typing import TYPE_CHECKING, Any, override
+
+import voluptuous as vol
+
+from homeassistant.components.infrared import (
+ DOMAIN as INFRARED_DOMAIN,
+ async_get_emitters,
+)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.selector import (
+ EntitySelector,
+ EntitySelectorConfig,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+
+from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType
+
+DEVICE_NAMES = {
+ LEDIrDeviceType.GENERIC_24_KEY: "24-key remote",
+ LEDIrDeviceType.GENERIC_13_KEY: "13-key remote",
+}
+
+
+class LEDIrConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for LED Infrared."""
+
+ @override
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors: dict[str, str] = {}
+ emitter_entity_ids = async_get_emitters(self.hass)
+ if not emitter_entity_ids:
+ return self.async_abort(reason="no_infrared_entities")
+
+ if user_input is not None:
+ emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID)
+ if emitter_id:
+ self._async_abort_entries_match(
+ {
+ CONF_DEVICE_TYPE: user_input[CONF_DEVICE_TYPE],
+ CONF_INFRARED_ENTITY_ID: emitter_id,
+ }
+ )
+
+ title_entity_id = emitter_id
+ if TYPE_CHECKING:
+ assert title_entity_id is not None
+ ent_reg = er.async_get(self.hass)
+ entry = ent_reg.async_get(title_entity_id)
+ title_entity_name = (
+ entry.name or entry.original_name or title_entity_id
+ if entry
+ else title_entity_id
+ )
+ return self.async_create_entry(
+ title=f"LED light with {DEVICE_NAMES[LEDIrDeviceType(user_input[CONF_DEVICE_TYPE])]} via {title_entity_name}",
+ data=user_input,
+ )
+
+ errors["base"] = "missing_infrared_entity"
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_DEVICE_TYPE): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ device_type.value for device_type in LEDIrDeviceType
+ ],
+ translation_key=CONF_DEVICE_TYPE,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector(
+ EntitySelectorConfig(
+ domain=INFRARED_DOMAIN,
+ include_entities=emitter_entity_ids,
+ )
+ ),
+ }
+ ),
+ errors=errors,
+ description_placeholders={
+ "docs_url": "https://www.home-assistant.io/integrations/led_infrared"
+ },
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfigure flow."""
+ errors: dict[str, str] = {}
+
+ entry = self._get_reconfigure_entry()
+
+ emitter_entity_ids = async_get_emitters(self.hass)
+ if not emitter_entity_ids:
+ return self.async_abort(reason="no_infrared_entities")
+
+ if user_input is not None:
+ emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID)
+ if emitter_id:
+ self._async_abort_entries_match(
+ {
+ CONF_DEVICE_TYPE: entry.data[CONF_DEVICE_TYPE],
+ CONF_INFRARED_ENTITY_ID: emitter_id,
+ }
+ )
+ return self.async_update_reload_and_abort(
+ entry, data_updates=user_input
+ )
+
+ errors["base"] = "missing_infrared_entity"
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector(
+ EntitySelectorConfig(
+ domain=INFRARED_DOMAIN,
+ include_entities=emitter_entity_ids,
+ )
+ )
+ }
+ ),
+ entry.data,
+ ),
+ errors=errors,
+ )
diff --git a/homeassistant/components/led_infrared/const.py b/homeassistant/components/led_infrared/const.py
new file mode 100644
index 000000000000..7c5295f2b586
--- /dev/null
+++ b/homeassistant/components/led_infrared/const.py
@@ -0,0 +1,14 @@
+"""Constants for the LED Infrared integration."""
+
+from enum import StrEnum
+
+DOMAIN = "led_infrared"
+CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
+CONF_DEVICE_TYPE = "device_type"
+
+
+class LEDIrDeviceType(StrEnum):
+ """LED Infrared device types."""
+
+ GENERIC_24_KEY = "generic_24_key"
+ GENERIC_13_KEY = "generic_13_key"
diff --git a/homeassistant/components/led_infrared/diagnostics.py b/homeassistant/components/led_infrared/diagnostics.py
new file mode 100644
index 000000000000..cd74ce1614ee
--- /dev/null
+++ b/homeassistant/components/led_infrared/diagnostics.py
@@ -0,0 +1,14 @@
+"""Diagnostics platform for the LED Infrared integration."""
+
+from typing import Any
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ return dict(config_entry.data)
diff --git a/homeassistant/components/led_infrared/icons.json b/homeassistant/components/led_infrared/icons.json
new file mode 100644
index 000000000000..d1e1784e95ce
--- /dev/null
+++ b/homeassistant/components/led_infrared/icons.json
@@ -0,0 +1,42 @@
+{
+ "entity": {
+ "light": {
+ "light": {
+ "state_attributes": {
+ "effect": {
+ "state": {
+ "blue": "mdi:palette",
+ "cyan": "mdi:palette",
+ "dark_cyan": "mdi:palette",
+ "fade": "mdi:gradient-horizontal",
+ "flash": "mdi:flash",
+ "green": "mdi:palette",
+ "light_green": "mdi:palette",
+ "mode_1": "mdi:numeric-1-box",
+ "mode_2": "mdi:numeric-2-box",
+ "mode_3": "mdi:numeric-3-box",
+ "mode_4": "mdi:numeric-4-box",
+ "mode_5": "mdi:numeric-5-box",
+ "mode_6": "mdi:numeric-6-box",
+ "mode_7": "mdi:numeric-7-box",
+ "mode_8": "mdi:numeric-8-box",
+ "orange": "mdi:palette",
+ "orange_red": "mdi:palette",
+ "plum": "mdi:palette",
+ "purple": "mdi:palette",
+ "rebecca_purple": "mdi:palette",
+ "red": "mdi:palette",
+ "sky_blue": "mdi:palette",
+ "smooth": "mdi:looks",
+ "strobe": "mdi:light-flood-down",
+ "tomato": "mdi:palette",
+ "turquoise": "mdi:palette",
+ "white": "mdi:palette",
+ "yellow": "mdi:palette"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/led_infrared/light.py b/homeassistant/components/led_infrared/light.py
new file mode 100644
index 000000000000..7f0d385218a1
--- /dev/null
+++ b/homeassistant/components/led_infrared/light.py
@@ -0,0 +1,129 @@
+"""Light platform for LED Infrared integration."""
+
+from typing import Any, override
+
+from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode
+
+from homeassistant.components.infrared import InfraredEmitterConsumerEntity
+from homeassistant.components.light import (
+ ATTR_EFFECT,
+ ColorMode,
+ LightEntity,
+ LightEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType
+
+PARALLEL_UPDATES = 1
+
+CODES = {
+ LEDIrDeviceType.GENERIC_24_KEY: Generic24KeyCode,
+ LEDIrDeviceType.GENERIC_13_KEY: Generic13KeyCode,
+}
+
+
+SUPPORTED_EFFECTS = {
+ LEDIrDeviceType.GENERIC_24_KEY: ["flash", "strobe", "fade", "smooth"],
+ LEDIrDeviceType.GENERIC_13_KEY: [
+ "mode_1",
+ "mode_2",
+ "mode_3",
+ "mode_4",
+ "mode_5",
+ "mode_6",
+ "mode_7",
+ "mode_8",
+ ],
+}
+
+
+SUPPORTED_COLORS = {
+ LEDIrDeviceType.GENERIC_24_KEY: [
+ "red",
+ "green",
+ "blue",
+ "white",
+ "tomato",
+ "light_green",
+ "sky_blue",
+ "orange_red",
+ "cyan",
+ "rebecca_purple",
+ "orange",
+ "turquoise",
+ "purple",
+ "yellow",
+ "dark_cyan",
+ "plum",
+ ],
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up platform from config entry."""
+ if not (infrared_entity_id := entry.data.get(CONF_INFRARED_ENTITY_ID)):
+ return
+
+ async_add_entities(
+ [LEDIrLightEntity(entry, entry.data[CONF_DEVICE_TYPE], infrared_entity_id)]
+ )
+
+
+class LEDIrLightEntity(InfraredEmitterConsumerEntity, LightEntity):
+ """Represents a LED Infrared light entity."""
+
+ _attr_assumed_state = True
+ _attr_color_mode = ColorMode.ONOFF
+ _attr_effect_list: list[str]
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_supported_color_modes = {ColorMode.ONOFF}
+ _attr_supported_features = LightEntityFeature.EFFECT
+ _attr_translation_key = "light"
+
+ def __init__(
+ self,
+ entry: ConfigEntry,
+ device_type: LEDIrDeviceType,
+ infrared_entity_id: str,
+ ) -> None:
+ """Initialize the entity."""
+ self._attr_unique_id = entry.entry_id
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, entry.entry_id)},
+ name=entry.title,
+ )
+
+ self._infrared_emitter_entity_id = infrared_entity_id
+
+ self._codes = CODES[device_type]
+ self._attr_effect_list = SUPPORTED_EFFECTS.get(
+ device_type, []
+ ) + SUPPORTED_COLORS.get(device_type, [])
+
+ @override
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn device on."""
+ await self._send_command(self._codes.ON.to_command())
+ self._attr_is_on = True
+ effect: str | None = kwargs.get(ATTR_EFFECT)
+ if effect and effect in self._attr_effect_list:
+ await self._send_command(self._codes[effect.upper()].to_command())
+ self._attr_effect = effect
+
+ self.async_write_ha_state()
+
+ @override
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ await self._send_command(self._codes.OFF.to_command())
+ self._attr_is_on = False
+ self.async_write_ha_state()
diff --git a/homeassistant/components/led_infrared/manifest.json b/homeassistant/components/led_infrared/manifest.json
new file mode 100644
index 000000000000..501f79a54b73
--- /dev/null
+++ b/homeassistant/components/led_infrared/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "led_infrared",
+ "name": "LED Infrared",
+ "codeowners": ["@tr4nt0r"],
+ "config_flow": true,
+ "dependencies": ["infrared"],
+ "documentation": "https://www.home-assistant.io/integrations/led_infrared",
+ "integration_type": "device",
+ "iot_class": "assumed_state",
+ "quality_scale": "bronze"
+}
diff --git a/homeassistant/components/led_infrared/quality_scale.yaml b/homeassistant/components/led_infrared/quality_scale.yaml
new file mode 100644
index 000000000000..5119557d203f
--- /dev/null
+++ b/homeassistant/components/led_infrared/quality_scale.yaml
@@ -0,0 +1,120 @@
+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: exempt
+ comment: This integration has only one platform
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-conditions:
+ status: exempt
+ comment: This integration does not have any conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: This integration does not have any triggers.
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data:
+ status: exempt
+ comment: |
+ This integration does not store runtime data.
+ test-before-configure:
+ status: exempt
+ comment: |
+ This integration only proxies commands through an existing infrared
+ entity, so there is no separate connection to validate during config flow.
+ test-before-setup:
+ status: exempt
+ comment: |
+ This integration only proxies commands through an existing infrared
+ entity, so there is no separate connection to validate during setup.
+ unique-config-entry: done
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not register custom actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ This integration is configured manually via config flow.
+ 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: |
+ Each config entry creates a single device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ No entities should be disabled by default
+ entity-translations: done
+ exception-translations:
+ status: exempt
+ comment: |
+ This integration does not raise exceptions.
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration has no repairs.
+ stale-devices:
+ status: exempt
+ comment: |
+ Each config entry manages exactly one device.
+
+ # Platinum
+ async-dependency:
+ status: exempt
+ comment: |
+ This integration depends on infrared_protocols which provides only code
+ definitions with no I/O, so async dependency does not apply.
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration does not do HTTP requests.
+ strict-typing: done
diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json
new file mode 100644
index 000000000000..a7735543a8b6
--- /dev/null
+++ b/homeassistant/components/led_infrared/strings.json
@@ -0,0 +1,83 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "This device has already been configured with this infrared entity.",
+ "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ },
+ "error": {
+ "missing_infrared_entity": "Select an infrared emitter."
+ },
+ "step": {
+ "reconfigure": {
+ "data": {
+ "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]"
+ },
+ "data_description": {
+ "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]"
+ },
+ "title": "Reconfigure LED Infrared device"
+ },
+ "user": {
+ "data": {
+ "device_type": "[%key:common::generic::device_type%]",
+ "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]"
+ },
+ "data_description": {
+ "device_type": "The type of remote control used for the LED light bulb, lamp, or controller.",
+ "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]"
+ },
+ "description": "Select the device type and an infrared emitter. You can identify the correct device based on the remote control used. Please refer to the [documentation]({docs_url}).",
+ "title": "Set up LED Infrared device"
+ }
+ }
+ },
+ "entity": {
+ "light": {
+ "light": {
+ "state_attributes": {
+ "effect": {
+ "state": {
+ "blue": "Color: Blue",
+ "cyan": "Color: Cyan",
+ "dark_cyan": "Color: Dark cyan",
+ "fade": "Fade",
+ "flash": "Flash",
+ "green": "Color: Green",
+ "light_green": "Color: Light green",
+ "mode_1": "Mode 1",
+ "mode_2": "Mode 2",
+ "mode_3": "Mode 3",
+ "mode_4": "Mode 4",
+ "mode_5": "Mode 5",
+ "mode_6": "Mode 6",
+ "mode_7": "Mode 7",
+ "mode_8": "Mode 8",
+ "orange": "Color: Orange",
+ "orange_red": "Color: Orange red",
+ "plum": "Color: Plum",
+ "purple": "Color: Purple",
+ "rebecca_purple": "Color: Rebecca purple",
+ "red": "Color: Red",
+ "sky_blue": "Color: Sky blue",
+ "smooth": "Smooth",
+ "strobe": "Strobe",
+ "tomato": "Color: Tomato",
+ "turquoise": "Color: Turquoise",
+ "white": "Color: White",
+ "yellow": "Color: Yellow"
+ }
+ }
+ }
+ }
+ }
+ },
+ "selector": {
+ "device_type": {
+ "options": {
+ "generic_13_key": "13-key remote control",
+ "generic_24_key": "24-key remote control"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/lg_soundbar/__init__.py b/homeassistant/components/lg_soundbar/__init__.py
index f32473c52811..6df848cfa15b 100644
--- a/homeassistant/components/lg_soundbar/__init__.py
+++ b/homeassistant/components/lg_soundbar/__init__.py
@@ -7,7 +7,6 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.exceptions import ConfigEntryNotReady
from .config_flow import test_connect
-from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -18,7 +17,6 @@ async def async_setup_entry(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
"""Set up platform from a ConfigEntry."""
- hass.data.setdefault(DOMAIN, {})
# Verify the device is reachable with the given
# config before setting up the platform
try:
diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py
index b26364695429..b0254a11e27b 100644
--- a/homeassistant/components/lg_thinq/climate.py
+++ b/homeassistant/components/lg_thinq/climate.py
@@ -11,6 +11,7 @@ from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
+ FAN_AUTO,
FAN_MEDIUM,
PRESET_NONE,
SWING_OFF,
@@ -66,6 +67,7 @@ SWING_TO_STR = {v: k for k, v in STR_TO_SWING.items()}
STR_TO_HA_FAN: dict[str, str] = {
"mid": FAN_MEDIUM,
+ "nature": FAN_AUTO,
}
HA_FAN_TO_STR = {v: k for k, v in STR_TO_HA_FAN.items()}
@@ -276,16 +278,20 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
@override
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
+ # Handle both reported fan mode variants ("auto" and "nature") safely.
+ thinq_fan_mode = HA_FAN_TO_STR.get(fan_mode, fan_mode)
+ if thinq_fan_mode not in self.data.fan_modes:
+ thinq_fan_mode = fan_mode
_LOGGER.debug(
"[%s:%s] async_set_fan_mode: %s",
self.coordinator.device_name,
self.property_id,
- fan_mode,
+ thinq_fan_mode,
)
await self.async_call_api(
self.coordinator.api.async_set_fan_mode(
self.property_id,
- HA_FAN_TO_STR.get(fan_mode, fan_mode),
+ thinq_fan_mode,
)
)
diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py
index 0e614a8b363b..5cce4f3857a3 100644
--- a/homeassistant/components/lg_thinq/entity.py
+++ b/homeassistant/components/lg_thinq/entity.py
@@ -4,12 +4,13 @@ from collections.abc import Callable, Coroutine
import logging
from typing import Any, override
+from aiohttp import ClientError
from thinqconnect import ThinQAPIException
from thinqconnect.devices.const import Location
from thinqconnect.integration import PropertyState
from homeassistant.core import callback
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -112,3 +113,10 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
if on_fail_method:
on_fail_method()
raise ServiceValidationError(exc) from exc
+ except (TimeoutError, ClientError) as exc:
+ if on_fail_method:
+ on_fail_method()
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ ) from exc
diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json
index abd8d4d50d07..dd739a8ca21e 100644
--- a/homeassistant/components/lg_thinq/strings.json
+++ b/homeassistant/components/lg_thinq/strings.json
@@ -72,11 +72,15 @@
"fan_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
+ "breeze": "Light breeze",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
+ "low_mid": "Low medium",
"medium": "[%key:common::state::medium%]",
+ "mid_high": "Medium high",
"power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]",
- "slow": "Slow"
+ "slow": "Slow",
+ "slow_low": "Super low"
}
},
"preset_mode": {
@@ -1159,6 +1163,9 @@
}
},
"exceptions": {
+ "connection_error": {
+ "message": "Failed to connect to the LG ThinQ cloud. Please try again later."
+ },
"failed_to_connect_mqtt": {
"message": "Failed to connect MQTT: {error}"
}
diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py
index cab183044d38..3f997167dd36 100644
--- a/homeassistant/components/lg_thinq/vacuum.py
+++ b/homeassistant/components/lg_thinq/vacuum.py
@@ -65,15 +65,6 @@ ROBOT_STATUS_TO_HA = {
"clean_select_gozone": VacuumActivity.CLEANING,
"error": VacuumActivity.ERROR,
}
-ROBOT_BATT_TO_HA = {
- "moveless": 5,
- "dock_level": 5,
- "low": 30,
- "mid": 50,
- "high": 90,
- "full": 100,
- "over_charge": 100,
-}
_LOGGER = logging.getLogger(__name__)
@@ -106,7 +97,6 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity):
_attr_supported_features = (
VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.STATE
- | VacuumEntityFeature.BATTERY
| VacuumEntityFeature.START
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
@@ -120,19 +110,12 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity):
# Update state.
self._attr_activity = ROBOT_STATUS_TO_HA.get(self.data.current_state)
- # Update battery.
- if (level := self.data.battery) is not None:
- self._attr_battery_level = (
- level if isinstance(level, int) else ROBOT_BATT_TO_HA.get(level, 0)
- )
-
_LOGGER.debug(
- "[%s:%s] update status: %s -> %s (battery_level=%s)",
+ "[%s:%s] update status: %s -> %s",
self.coordinator.device_name,
self.property_id,
self.data.current_state,
self.state,
- self.battery_level,
)
@override
diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py
index 1fa5231a8db3..577cd1d737ba 100644
--- a/homeassistant/components/liebherr/__init__.py
+++ b/homeassistant/components/liebherr/__init__.py
@@ -24,6 +24,7 @@ from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
+ Platform.COVER,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
@@ -44,11 +45,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
try:
devices = await client.get_devices()
except LiebherrAuthenticationError as err:
- # pylint: disable-next=home-assistant-exception-not-translated
- raise ConfigEntryAuthFailed("Invalid API key") from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="invalid_api_key",
+ ) from err
except LiebherrConnectionError as err:
- # pylint: disable-next=home-assistant-exception-not-translated
- raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ ) from err
# Create a coordinator for each device (may be empty if no devices)
data = LiebherrData(client=client)
@@ -101,10 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
for device_id in device_ids:
if coordinator := data.coordinators.pop(device_id, None):
await coordinator.async_shutdown()
- device_registry.async_update_device(
- device_id=device_entry.id,
- remove_config_entry_id=entry.entry_id,
- )
+ device_registry.async_remove_device(device_entry.id)
# Add new devices
new_coordinators: list[LiebherrCoordinator] = []
diff --git a/homeassistant/components/liebherr/coordinator.py b/homeassistant/components/liebherr/coordinator.py
index 6d9ae1004640..32887b08022a 100644
--- a/homeassistant/components/liebherr/coordinator.py
+++ b/homeassistant/components/liebherr/coordinator.py
@@ -60,12 +60,15 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
try:
await self.client.get_device(self.device_id)
except LiebherrAuthenticationError as err:
- # pylint: disable-next=home-assistant-exception-not-translated
- raise ConfigEntryAuthFailed("Invalid API key") from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="invalid_api_key",
+ ) from err
except LiebherrConnectionError as err:
- # pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryNotReady(
- f"Failed to connect to device {self.device_id}: {err}"
+ translation_domain=DOMAIN,
+ translation_key="device_connection_error",
+ translation_placeholders={"device_id": self.device_id},
) from err
@override
@@ -74,15 +77,19 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
try:
return await self.client.get_device_state(self.device_id)
except LiebherrAuthenticationError as err:
- # pylint: disable-next=home-assistant-exception-not-translated
- raise ConfigEntryAuthFailed("API key is no longer valid") from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_expired",
+ ) from err
except LiebherrTimeoutError as err:
- # pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(
- f"Timeout communicating with device {self.device_id}"
+ translation_domain=DOMAIN,
+ translation_key="device_timeout_error",
+ translation_placeholders={"device_id": self.device_id},
) from err
except LiebherrConnectionError as err:
- # pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(
- f"Error communicating with device {self.device_id}"
+ translation_domain=DOMAIN,
+ translation_key="device_communication_error",
+ translation_placeholders={"device_id": self.device_id},
) from err
diff --git a/homeassistant/components/liebherr/cover.py b/homeassistant/components/liebherr/cover.py
new file mode 100644
index 000000000000..e6b8addf4c4c
--- /dev/null
+++ b/homeassistant/components/liebherr/cover.py
@@ -0,0 +1,171 @@
+"""Cover platform for Liebherr integration."""
+
+from typing import Any, override
+
+from pyliebherrhomeapi import AutoDoorControl, DoorState, ZonePosition
+
+from homeassistant.components.cover import (
+ CoverDeviceClass,
+ CoverEntity,
+ CoverEntityFeature,
+)
+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 AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
+from .entity import ZONE_POSITION_MAP, LiebherrEntity
+
+PARALLEL_UPDATES = 1
+
+
+def _create_cover_entities(
+ coordinators: list[LiebherrCoordinator],
+) -> list[LiebherrAutoDoor]:
+ """Create cover entities for the given coordinators."""
+ entities: list[LiebherrAutoDoor] = []
+
+ for coordinator in coordinators:
+ has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
+
+ entities.extend(
+ LiebherrAutoDoor(
+ coordinator=coordinator,
+ zone_id=zone_id,
+ has_multiple_zones=has_multiple_zones,
+ )
+ for zone_id in coordinator.data.get_auto_door_controls()
+ )
+
+ return entities
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LiebherrConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Liebherr cover entities."""
+ async_add_entities(
+ _create_cover_entities(list(entry.runtime_data.coordinators.values()))
+ )
+
+ @callback
+ def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
+ """Add cover entities for new devices."""
+ async_add_entities(_create_cover_entities(coordinators))
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
+ )
+ )
+
+
+class LiebherrAutoDoor(LiebherrEntity, CoverEntity):
+ """Representation of a Liebherr auto door."""
+
+ _attr_device_class = CoverDeviceClass.DOOR
+ _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+ _attr_translation_key = "auto_door"
+ _optimistic_state: bool | None = None
+
+ def __init__(
+ self,
+ coordinator: LiebherrCoordinator,
+ zone_id: int,
+ has_multiple_zones: bool,
+ ) -> None:
+ """Initialize the auto door entity."""
+ super().__init__(coordinator)
+ self._zone_id = zone_id
+ self._attr_unique_id = f"{coordinator.device_id}_auto_door_{zone_id}"
+
+ # Add zone suffix only for multi-zone devices
+ if has_multiple_zones:
+ temp_controls = coordinator.data.get_temperature_controls()
+ if (
+ (tc := temp_controls.get(zone_id))
+ and isinstance(tc.zone_position, ZonePosition)
+ and (zone_key := ZONE_POSITION_MAP.get(tc.zone_position))
+ ):
+ self._attr_translation_key = f"auto_door_{zone_key}"
+
+ @property
+ def _auto_door_control(self) -> AutoDoorControl | None:
+ """Get the auto door control for this zone."""
+ return self.coordinator.data.get_auto_door_controls().get(self._zone_id)
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return super().available and self._auto_door_control is not None
+
+ @callback
+ @override
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._optimistic_state = None
+ super()._handle_coordinator_update()
+
+ @property
+ @override
+ def is_closed(self) -> bool | None:
+ """Return if the door is closed."""
+ if self._optimistic_state is not None:
+ return False
+ control = self._auto_door_control
+ if control is None or control.value is None:
+ return None
+ return control.value == DoorState.CLOSED
+
+ @property
+ @override
+ def is_opening(self) -> bool | None:
+ """Return if the door is opening."""
+ if self._optimistic_state is None:
+ return False
+ return self._optimistic_state
+
+ @property
+ @override
+ def is_closing(self) -> bool | None:
+ """Return if the door is closing."""
+ if self._optimistic_state is None:
+ return False
+ return not self._optimistic_state
+
+ async def _async_set_door(self, value: bool) -> None:
+ """Open or close the door."""
+ self._optimistic_state = value
+ self.async_write_ha_state()
+ try:
+ await self._async_send_command(
+ self.coordinator.client.trigger_auto_door(
+ device_id=self.coordinator.device_id,
+ zone_id=self._zone_id,
+ value=value,
+ )
+ )
+ except HomeAssistantError as err:
+ self._optimistic_state = None
+ self.async_write_ha_state()
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="open_auto_door_error"
+ if value
+ else "close_auto_door_error",
+ ) from err
+
+ @override
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the door."""
+ await self._async_set_door(True)
+
+ @override
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close the door."""
+ await self._async_set_door(False)
diff --git a/homeassistant/components/liebherr/strings.json b/homeassistant/components/liebherr/strings.json
index 06d557f1eacf..ea44defe44e1 100644
--- a/homeassistant/components/liebherr/strings.json
+++ b/homeassistant/components/liebherr/strings.json
@@ -33,6 +33,20 @@
}
},
"entity": {
+ "cover": {
+ "auto_door": {
+ "name": "AutoDoor"
+ },
+ "auto_door_bottom_zone": {
+ "name": "Bottom zone AutoDoor"
+ },
+ "auto_door_middle_zone": {
+ "name": "Middle zone AutoDoor"
+ },
+ "auto_door_top_zone": {
+ "name": "Top zone AutoDoor"
+ }
+ },
"light": {
"presentation_light": {
"name": "Presentation light"
@@ -203,8 +217,32 @@
}
},
"exceptions": {
+ "auth_expired": {
+ "message": "API key is no longer valid"
+ },
+ "cannot_connect": {
+ "message": "Failed to connect to the Liebherr API"
+ },
+ "close_auto_door_error": {
+ "message": "An error occurred while closing the door"
+ },
"communication_error": {
"message": "An error occurred while communicating with the device"
+ },
+ "device_communication_error": {
+ "message": "Error communicating with device {device_id}"
+ },
+ "device_connection_error": {
+ "message": "Failed to connect to device {device_id}"
+ },
+ "device_timeout_error": {
+ "message": "Timeout communicating with device {device_id}"
+ },
+ "invalid_api_key": {
+ "message": "Invalid API key"
+ },
+ "open_auto_door_error": {
+ "message": "An error occurred while opening the door"
}
}
}
diff --git a/homeassistant/components/lifx/migration.py b/homeassistant/components/lifx/migration.py
index 60aded43fb90..e84b2b465d3c 100644
--- a/homeassistant/components/lifx/migration.py
+++ b/homeassistant/components/lifx/migration.py
@@ -61,8 +61,7 @@ def async_migrate_entities_devices(
migrated_devices.append(dev_entry.id)
device_registry.async_update_device(
dev_entry.id,
- add_config_entry_id=new_entry.entry_id,
- remove_config_entry_id=legacy_entry_id,
+ new_config_entry_id=new_entry.entry_id,
)
entity_registry = er.async_get(hass)
diff --git a/homeassistant/components/light/llm.py b/homeassistant/components/light/llm.py
new file mode 100644
index 000000000000..5570245444f1
--- /dev/null
+++ b/homeassistant/components/light/llm.py
@@ -0,0 +1,38 @@
+"""LLM tools for the light integration."""
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from .const import DOMAIN
+from .intent import INTENT_SET
+
+# Intents owned by this integration that are exposed as LLM tools.
+LLM_INTENTS = (INTENT_SET,)
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return LLM tools for the integration's intents when its domain is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ if not llm_context.assistant:
+ return None
+
+ if not any(
+ async_should_expose(hass, llm_context.assistant, state.entity_id)
+ for state in hass.states.async_all(DOMAIN)
+ ):
+ return None
+
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type in LLM_INTENTS
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py
index 14b7df583341..bd44820fde92 100644
--- a/homeassistant/components/limitlessled/light.py
+++ b/homeassistant/components/limitlessled/light.py
@@ -29,6 +29,7 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
LightEntityFeature,
+ LightEntityStateAttribute,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_ON
from homeassistant.core import HomeAssistant
@@ -259,11 +260,15 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity):
await super().async_added_to_hass()
if last_state := await self.async_get_last_state():
self._attr_is_on = last_state.state == STATE_ON
- self._attr_brightness = last_state.attributes.get("brightness")
+ self._attr_brightness = last_state.attributes.get(
+ LightEntityStateAttribute.BRIGHTNESS
+ )
self._attr_color_temp_kelvin = last_state.attributes.get(
- "color_temp_kelvin"
+ LightEntityStateAttribute.COLOR_TEMP_KELVIN
+ )
+ self._attr_hs_color = last_state.attributes.get(
+ LightEntityStateAttribute.HS_COLOR
)
- self._attr_hs_color = last_state.attributes.get("hs_color")
@property
@override
diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py
index fc2a9157e65e..acef5fec0d46 100644
--- a/homeassistant/components/linkplay/config_flow.py
+++ b/homeassistant/components/linkplay/config_flow.py
@@ -36,6 +36,17 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN):
# Do not probe the device if the host is already configured
self._async_abort_entries_match({CONF_HOST: discovery_info.host})
+ # Do not probe the device if the UUID advertised over mDNS matches
+ # an existing (or ignored) entry
+ if uuid := discovery_info.properties.get("uuid"):
+ # The advertised UUID is prefixed and dashed
+ # (uuid:FF31F09E-5001-...), while the device API (and therefore
+ # the stored unique id) uses the dashless form
+ await self.async_set_unique_id(uuid.removeprefix("uuid:").replace("-", ""))
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: discovery_info.host}
+ )
+
session: ClientSession = await async_get_client_session(self.hass)
bridge: LinkPlayBridge | None = None
diff --git a/homeassistant/components/litellm/__init__.py b/homeassistant/components/litellm/__init__.py
new file mode 100644
index 000000000000..5447eeb01454
--- /dev/null
+++ b/homeassistant/components/litellm/__init__.py
@@ -0,0 +1,33 @@
+"""The LiteLLM integration."""
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator
+
+PLATFORMS = [Platform.CONVERSATION]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool:
+ """Set up LiteLLM from a config entry."""
+ coordinator = LiteLLMDataUpdateCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ entry.async_on_unload(entry.add_update_listener(_async_update_listener))
+
+ return True
+
+
+async def _async_update_listener(
+ hass: HomeAssistant, entry: LiteLLMConfigEntry
+) -> None:
+ """Handle update."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool:
+ """Unload LiteLLM."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py
new file mode 100644
index 000000000000..0b8df8be1d44
--- /dev/null
+++ b/homeassistant/components/litellm/config_flow.py
@@ -0,0 +1,253 @@
+"""Config flow for LiteLLM integration."""
+
+import logging
+from typing import Any, override
+
+from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import (
+ SOURCE_USER,
+ ConfigEntry,
+ ConfigEntryState,
+ ConfigFlow,
+ ConfigFlowResult,
+ ConfigSubentryFlow,
+ SubentryFlowResult,
+)
+from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import llm
+from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+ TemplateSelector,
+)
+
+from .const import (
+ CONF_PROMPT,
+ DOMAIN,
+ PLACEHOLDER_API_KEY,
+ RECOMMENDED_CONVERSATION_OPTIONS,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect to the proxy."""
+
+
+class InvalidAuth(HomeAssistantError):
+ """Error to indicate the API key is invalid."""
+
+
+def _normalize_url(url: str) -> str:
+ """Normalize the proxy URL, ensuring it ends with the OpenAI `/v1` path."""
+ parsed = URL(url.strip())
+ path = parsed.path.rstrip("/")
+ if not path.endswith("/v1"):
+ path = f"{path}/v1"
+ return str(parsed.with_path(path))
+
+
+async def _get_models(hass: HomeAssistant, url: str, api_key: str | None) -> list[str]:
+ """Fetch the available model names from the LiteLLM proxy.
+
+ Uses the OpenAI-compatible `/v1/models` endpoint, which a LiteLLM proxy
+ serves with the configured model names.
+ """
+ client = AsyncOpenAI(
+ base_url=url,
+ api_key=api_key or PLACEHOLDER_API_KEY,
+ http_client=get_async_client(hass),
+ )
+ try:
+ return [
+ model.id async for model in client.with_options(timeout=10.0).models.list()
+ ]
+ except (AuthenticationError, PermissionDeniedError) as err:
+ raise InvalidAuth from err
+ except OpenAIError as err:
+ raise CannotConnect from err
+
+
+class LiteLLMConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for LiteLLM."""
+
+ VERSION = 1
+
+ @classmethod
+ @callback
+ @override
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this handler."""
+ return {"conversation": ConversationFlowHandler}
+
+ @override
+ 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:
+ url = _normalize_url(user_input[CONF_URL])
+ api_key = user_input.get(CONF_API_KEY)
+ self._async_abort_entries_match({CONF_URL: url})
+ try:
+ await _get_models(self.hass, url, api_key)
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ data = {CONF_URL: url}
+ if api_key:
+ data[CONF_API_KEY] = api_key
+ return self.async_create_entry(
+ title=URL(url).host or url,
+ data=data,
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_URL): str,
+ vol.Optional(CONF_API_KEY): str,
+ }
+ ),
+ errors=errors,
+ )
+
+
+class LiteLLMSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow for LiteLLM."""
+
+ def __init__(self) -> None:
+ """Initialize the subentry flow."""
+ self.models: list[str] = []
+
+ async def _fetch_models(self) -> None:
+ """Fetch models from the LiteLLM proxy."""
+ entry = self._get_entry()
+ self.models = await _get_models(
+ self.hass, entry.data[CONF_URL], entry.data.get(CONF_API_KEY)
+ )
+
+
+class ConversationFlowHandler(LiteLLMSubentryFlowHandler):
+ """Handle conversation subentry flow."""
+
+ def __init__(self) -> None:
+ """Initialize the subentry flow."""
+ super().__init__()
+ self.options: dict[str, Any] = {}
+
+ @property
+ def _is_new(self) -> bool:
+ """Return if this is a new subentry."""
+ return self.source == SOURCE_USER
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to create a conversation agent."""
+ self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
+ return await self.async_step_init(user_input)
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Handle reconfiguration of a conversation agent."""
+ self.options = self._get_reconfigure_subentry().data.copy()
+ return await self.async_step_init(user_input)
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Manage conversation agent configuration."""
+ if self._get_entry().state is not ConfigEntryState.LOADED:
+ return self.async_abort(reason="entry_not_loaded")
+
+ if user_input is not None:
+ if not user_input.get(CONF_LLM_HASS_API):
+ user_input.pop(CONF_LLM_HASS_API, None)
+ if self._is_new:
+ return self.async_create_entry(
+ title=user_input[CONF_MODEL], data=user_input
+ )
+ return self.async_update_and_abort(
+ self._get_entry(),
+ self._get_reconfigure_subentry(),
+ title=user_input[CONF_MODEL],
+ data=user_input,
+ )
+
+ try:
+ await self._fetch_models()
+ except InvalidAuth:
+ return self.async_abort(reason="invalid_auth")
+ except CannotConnect:
+ return self.async_abort(reason="cannot_connect")
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ return self.async_abort(reason="unknown")
+
+ options = [SelectOptionDict(value=model, label=model) for model in self.models]
+
+ hass_apis: list[SelectOptionDict] = [
+ SelectOptionDict(
+ label=api.name,
+ value=api.id,
+ )
+ for api in llm.async_get_apis(self.hass)
+ ]
+
+ if suggested_llm_apis := self.options.get(CONF_LLM_HASS_API):
+ valid_api_ids = {api["value"] for api in hass_apis}
+ self.options[CONF_LLM_HASS_API] = [
+ api for api in suggested_llm_apis if api in valid_api_ids
+ ]
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_MODEL, default=self.options.get(CONF_MODEL)
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=options, mode=SelectSelectorMode.DROPDOWN, sort=True
+ ),
+ ),
+ vol.Optional(
+ CONF_PROMPT,
+ description={
+ "suggested_value": self.options.get(
+ CONF_PROMPT,
+ RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT],
+ )
+ },
+ ): TemplateSelector(),
+ vol.Optional(
+ CONF_LLM_HASS_API,
+ default=self.options.get(
+ CONF_LLM_HASS_API,
+ RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API],
+ ),
+ ): SelectSelector(
+ SelectSelectorConfig(options=hass_apis, multiple=True)
+ ),
+ }
+ ),
+ )
diff --git a/homeassistant/components/litellm/const.py b/homeassistant/components/litellm/const.py
new file mode 100644
index 000000000000..8f645e234519
--- /dev/null
+++ b/homeassistant/components/litellm/const.py
@@ -0,0 +1,18 @@
+"""Constants for the LiteLLM integration."""
+
+import logging
+
+from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT
+from homeassistant.helpers import llm
+
+DOMAIN = "litellm"
+LOGGER = logging.getLogger(__package__)
+
+# LiteLLM proxies may run without authentication. The OpenAI client requires a
+# non-empty API key, so we send a placeholder when the user did not provide one.
+PLACEHOLDER_API_KEY = "sk-no-key-required"
+
+RECOMMENDED_CONVERSATION_OPTIONS = {
+ CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
+ CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
+}
diff --git a/homeassistant/components/litellm/conversation.py b/homeassistant/components/litellm/conversation.py
new file mode 100644
index 000000000000..c6d979aba8dd
--- /dev/null
+++ b/homeassistant/components/litellm/conversation.py
@@ -0,0 +1,69 @@
+"""Conversation support for LiteLLM."""
+
+from typing import Literal, override
+
+from homeassistant.components import conversation
+from homeassistant.config_entries import ConfigSubentry
+from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import LiteLLMConfigEntry
+from .const import DOMAIN
+from .entity import LiteLLMEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: LiteLLMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up conversation entities."""
+ for subentry in config_entry.get_subentries_of_type("conversation"):
+ async_add_entities(
+ [LiteLLMConversationEntity(config_entry, subentry)],
+ config_subentry_id=subentry.subentry_id,
+ )
+
+
+class LiteLLMConversationEntity(LiteLLMEntity, conversation.ConversationEntity):
+ """LiteLLM conversation agent."""
+
+ _attr_name = None
+
+ def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None:
+ """Initialize the agent."""
+ super().__init__(entry, subentry)
+ if self.subentry.data.get(CONF_LLM_HASS_API):
+ self._attr_supported_features = (
+ conversation.ConversationEntityFeature.CONTROL
+ )
+
+ @property
+ @override
+ def supported_languages(self) -> list[str] | Literal["*"]:
+ """Return a list of supported languages."""
+ return MATCH_ALL
+
+ @override
+ async def _async_handle_message(
+ self,
+ user_input: conversation.ConversationInput,
+ chat_log: conversation.ChatLog,
+ ) -> conversation.ConversationResult:
+ """Process the user input and call the API."""
+ options = self.subentry.data
+
+ try:
+ await chat_log.async_provide_llm_data(
+ user_input.as_llm_context(DOMAIN),
+ options.get(CONF_LLM_HASS_API),
+ options.get(CONF_PROMPT),
+ user_input.extra_system_prompt,
+ )
+ except conversation.ConverseError as err:
+ return err.as_conversation_result()
+
+ await self._async_handle_chat_log(chat_log)
+
+ return conversation.async_get_result_from_chat_log(user_input, chat_log)
diff --git a/homeassistant/components/litellm/coordinator.py b/homeassistant/components/litellm/coordinator.py
new file mode 100644
index 000000000000..ecd856bf6fd8
--- /dev/null
+++ b/homeassistant/components/litellm/coordinator.py
@@ -0,0 +1,74 @@
+"""Coordinator for the LiteLLM integration."""
+
+from datetime import timedelta
+from typing import override
+
+from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, CONF_URL
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import LOGGER, PLACEHOLDER_API_KEY
+
+# Ping the proxy hourly while it is reachable, and back off to once a minute
+# while it is down so entities recover quickly once it returns.
+UPDATE_INTERVAL_CONNECTED = timedelta(hours=1)
+UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1)
+
+type LiteLLMConfigEntry = ConfigEntry[LiteLLMDataUpdateCoordinator]
+
+
+class LiteLLMDataUpdateCoordinator(DataUpdateCoordinator[None]):
+ """Own the OpenAI client and track LiteLLM proxy availability."""
+
+ config_entry: LiteLLMConfigEntry
+
+ def __init__(self, hass: HomeAssistant, config_entry: LiteLLMConfigEntry) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=config_entry,
+ name=config_entry.title,
+ update_interval=UPDATE_INTERVAL_CONNECTED,
+ always_update=False,
+ )
+ self.client = AsyncOpenAI(
+ base_url=config_entry.data[CONF_URL],
+ api_key=config_entry.data.get(CONF_API_KEY) or PLACEHOLDER_API_KEY,
+ http_client=get_async_client(hass),
+ )
+
+ @override
+ async def _async_update_data(self) -> None:
+ """Ping the proxy to confirm it is reachable and authenticated."""
+ self.update_interval = UPDATE_INTERVAL_DISCONNECTED
+ try:
+ async for _ in self.client.with_options(timeout=10.0).models.list():
+ break
+ except (AuthenticationError, PermissionDeniedError) as err:
+ raise ConfigEntryAuthFailed from err
+ except OpenAIError as err:
+ raise UpdateFailed(err) from err
+ self.update_interval = UPDATE_INTERVAL_CONNECTED
+
+ @callback
+ @override
+ def async_set_updated_data(self, data: None) -> None:
+ """Manually update data and reset to the connected interval."""
+ self.update_interval = UPDATE_INTERVAL_CONNECTED
+ super().async_set_updated_data(data)
+
+ @callback
+ def mark_connection_error(self) -> None:
+ """Flag the proxy as unreachable and schedule a quick recheck."""
+ self.update_interval = UPDATE_INTERVAL_DISCONNECTED
+ if self.last_update_success:
+ self.last_update_success = False
+ self.async_update_listeners()
+ if self._listeners and not self.hass.is_stopping:
+ self._schedule_refresh()
diff --git a/homeassistant/components/litellm/entity.py b/homeassistant/components/litellm/entity.py
new file mode 100644
index 000000000000..dfd37a0e0f76
--- /dev/null
+++ b/homeassistant/components/litellm/entity.py
@@ -0,0 +1,211 @@
+"""Base entity for LiteLLM."""
+
+from collections.abc import AsyncGenerator, Callable
+import json
+from typing import Any, Literal
+
+import openai
+from openai.types.chat import (
+ ChatCompletionAssistantMessageParam,
+ ChatCompletionFunctionToolParam,
+ ChatCompletionMessage,
+ ChatCompletionMessageFunctionToolCallParam,
+ ChatCompletionMessageParam,
+ ChatCompletionSystemMessageParam,
+ ChatCompletionToolMessageParam,
+ ChatCompletionUserMessageParam,
+)
+from openai.types.chat.chat_completion_message_function_tool_call_param import Function
+from openai.types.shared_params import FunctionDefinition
+from voluptuous_openapi import convert
+
+from homeassistant.components import conversation
+from homeassistant.config_entries import ConfigSubentry
+from homeassistant.const import CONF_MODEL
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr, llm
+from homeassistant.helpers.json import json_dumps
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, LOGGER
+from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator
+
+MAX_TOOL_ITERATIONS = 10
+
+
+def _format_tool(
+ tool: llm.Tool,
+ custom_serializer: Callable[[Any], Any] | None,
+) -> ChatCompletionFunctionToolParam:
+ """Format tool specification."""
+ unsupported_keys = {"oneOf", "anyOf", "allOf"}
+ schema = convert(tool.parameters, custom_serializer=custom_serializer)
+ schema = {k: v for k, v in schema.items() if k not in unsupported_keys}
+
+ tool_spec = FunctionDefinition(
+ name=tool.name,
+ parameters=schema,
+ )
+ if tool.description:
+ tool_spec["description"] = tool.description
+ return ChatCompletionFunctionToolParam(type="function", function=tool_spec)
+
+
+def _convert_content_to_chat_message(
+ content: conversation.Content,
+) -> ChatCompletionMessageParam | None:
+ """Convert any native chat message for this agent to the native format."""
+ LOGGER.debug("_convert_content_to_chat_message=%s", content)
+ if isinstance(content, conversation.ToolResultContent):
+ return ChatCompletionToolMessageParam(
+ role="tool",
+ tool_call_id=content.tool_call_id,
+ content=json_dumps(content.tool_result),
+ )
+
+ role: Literal["user", "assistant", "system"] = content.role
+ if role == "system" and content.content:
+ return ChatCompletionSystemMessageParam(role="system", content=content.content)
+
+ if role == "user" and content.content:
+ return ChatCompletionUserMessageParam(role="user", content=content.content)
+
+ if role == "assistant":
+ param = ChatCompletionAssistantMessageParam(
+ role="assistant",
+ content=content.content,
+ )
+ if isinstance(content, conversation.AssistantContent) and content.tool_calls:
+ param["tool_calls"] = [
+ ChatCompletionMessageFunctionToolCallParam(
+ type="function",
+ id=tool_call.id,
+ function=Function(
+ arguments=json_dumps(tool_call.tool_args),
+ name=tool_call.tool_name,
+ ),
+ )
+ for tool_call in content.tool_calls
+ ]
+ return param
+ LOGGER.warning("Could not convert message to Completions API: %s", content)
+ return None
+
+
+def _decode_tool_arguments(arguments: str) -> Any:
+ """Decode tool call arguments."""
+ try:
+ return json.loads(arguments)
+ except json.JSONDecodeError as err:
+ raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err
+
+
+async def _transform_response(
+ message: ChatCompletionMessage,
+) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
+ """Transform the LiteLLM message to a ChatLog format."""
+ data: conversation.AssistantContentDeltaDict = {
+ "role": message.role,
+ "content": message.content,
+ }
+ if message.tool_calls:
+ data["tool_calls"] = [
+ llm.ToolInput(
+ id=tool_call.id,
+ tool_name=tool_call.function.name,
+ tool_args=_decode_tool_arguments(tool_call.function.arguments),
+ )
+ for tool_call in message.tool_calls
+ if tool_call.type == "function"
+ ]
+ yield data
+
+
+class LiteLLMEntity(CoordinatorEntity[LiteLLMDataUpdateCoordinator]):
+ """Base entity for LiteLLM."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None:
+ """Initialize the entity."""
+ super().__init__(entry.runtime_data)
+ self.entry = entry
+ self.subentry = subentry
+ self.model = subentry.data[CONF_MODEL]
+ self._attr_unique_id = subentry.subentry_id
+ self._attr_device_info = dr.DeviceInfo(
+ identifiers={(DOMAIN, subentry.subentry_id)},
+ name=subentry.title,
+ entry_type=dr.DeviceEntryType.SERVICE,
+ )
+
+ async def _async_handle_chat_log(
+ self,
+ chat_log: conversation.ChatLog,
+ ) -> None:
+ """Generate an answer for the chat log."""
+ model_args = {
+ "model": self.model,
+ "user": chat_log.conversation_id,
+ }
+
+ tools: list[ChatCompletionFunctionToolParam] | 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 tools:
+ model_args["tools"] = tools
+
+ model_args["messages"] = [
+ m
+ for content in chat_log.content
+ if (m := _convert_content_to_chat_message(content))
+ ]
+
+ coordinator = self.entry.runtime_data
+ client = coordinator.client
+
+ for _iteration in range(MAX_TOOL_ITERATIONS):
+ try:
+ result = await client.chat.completions.create(**model_args)
+ except (openai.AuthenticationError, openai.PermissionDeniedError) as err:
+ # Re-check so the proxy is marked unavailable for the auth failure.
+ await coordinator.async_request_refresh()
+ LOGGER.error("Error talking to API: %s", err)
+ raise HomeAssistantError("Error talking to API") from err
+ except openai.APIConnectionError as err:
+ coordinator.mark_connection_error()
+ LOGGER.error("Error talking to API: %s", err)
+ raise HomeAssistantError("Error talking to API") from err
+ except openai.OpenAIError as err:
+ # Reachable but the request failed; keep the entity available.
+ coordinator.async_set_updated_data(None)
+ LOGGER.error("Error talking to API: %s", err)
+ raise HomeAssistantError("Error talking to API") from err
+
+ if not result.choices:
+ LOGGER.error("API returned empty choices")
+ raise HomeAssistantError("API returned empty response")
+
+ result_message = result.choices[0].message
+
+ model_args["messages"].extend(
+ [
+ msg
+ async for content in chat_log.async_add_delta_content_stream(
+ self.entity_id, _transform_response(result_message)
+ )
+ if (msg := _convert_content_to_chat_message(content))
+ ]
+ )
+ if not chat_log.unresponded_tool_results:
+ coordinator.async_set_updated_data(None)
+ break
+ else:
+ LOGGER.warning(
+ "Stopped after %s tool iterations with unresolved tool calls",
+ MAX_TOOL_ITERATIONS,
+ )
diff --git a/homeassistant/components/litellm/manifest.json b/homeassistant/components/litellm/manifest.json
new file mode 100644
index 000000000000..595ec0710b38
--- /dev/null
+++ b/homeassistant/components/litellm/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "litellm",
+ "name": "LiteLLM",
+ "after_dependencies": ["assist_pipeline", "intent"],
+ "codeowners": ["@luismalves"],
+ "config_flow": true,
+ "dependencies": ["conversation"],
+ "documentation": "https://www.home-assistant.io/integrations/litellm",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "quality_scale": "bronze",
+ "requirements": ["openai==2.45.0"]
+}
diff --git a/homeassistant/components/litellm/quality_scale.yaml b/homeassistant/components/litellm/quality_scale.yaml
new file mode 100644
index 000000000000..448664369678
--- /dev/null
+++ b/homeassistant/components/litellm/quality_scale.yaml
@@ -0,0 +1,98 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: No actions are implemented
+ appropriate-polling:
+ status: done
+ comment: >-
+ the coordinator polls the proxy hourly for an availability check, backing
+ off to once a minute while it is unreachable
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: No actions are implemented
+ docs-conditions:
+ status: exempt
+ comment: This integration does not have any conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: This integration does not have any triggers.
+ entity-event-setup:
+ status: exempt
+ comment: the integration does not 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: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: the integration has no options
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: done
+ comment: >-
+ the conversation entity follows the coordinator and is marked unavailable
+ when the proxy cannot be reached
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: Service can't be discovered
+ discovery:
+ status: exempt
+ comment: Service can't be discovered
+ 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: devices are created via subentries, not discovered dynamically
+ entity-category:
+ status: exempt
+ comment: the conversation entity does not use entity categories
+ entity-device-class:
+ status: exempt
+ comment: no suitable device class for the conversation entity
+ entity-disabled-by-default:
+ status: exempt
+ comment: only one conversation entity
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: the integration has no repairs
+ stale-devices:
+ status: exempt
+ comment: only one device per entry, is deleted with the entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/litellm/strings.json b/homeassistant/components/litellm/strings.json
new file mode 100644
index 000000000000..c13cf5122080
--- /dev/null
+++ b/homeassistant/components/litellm/strings.json
@@ -0,0 +1,55 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ },
+ "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%]"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]",
+ "url": "[%key:common::config_flow::data::url%]"
+ },
+ "data_description": {
+ "api_key": "An optional LiteLLM API key or virtual key. Leave empty if your proxy does not require authentication.",
+ "url": "The base URL of your LiteLLM proxy, including the host and port"
+ }
+ }
+ }
+ },
+ "config_subentries": {
+ "conversation": {
+ "abort": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "entry_type": "Conversation agent",
+ "initiate_flow": {
+ "reconfigure": "Reconfigure conversation agent",
+ "user": "Add conversation agent"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
+ "model": "[%key:common::generic::model%]",
+ "prompt": "[%key:common::config_flow::data::prompt%]"
+ },
+ "data_description": {
+ "llm_hass_api": "Select which tools the model can use to interact with your devices and entities.",
+ "model": "The model to use for the conversation agent",
+ "prompt": "Instruct how the LLM should respond. This can be a template."
+ },
+ "description": "Configure the conversation agent"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/llama_cpp/__init__.py b/homeassistant/components/llama_cpp/__init__.py
new file mode 100644
index 000000000000..0153fc32c2d0
--- /dev/null
+++ b/homeassistant/components/llama_cpp/__init__.py
@@ -0,0 +1,60 @@
+"""The llama.cpp integration."""
+
+import logging
+
+import openai
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryNotReady,
+ HomeAssistantError,
+)
+
+from .api import async_create_client, async_list_models
+
+_LOGGER = logging.getLogger(__name__)
+PLATFORMS = (Platform.CONVERSATION,)
+
+type LlamaCppConfigEntry = ConfigEntry[openai.AsyncOpenAI]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool:
+ """Set up llama.cpp from a config entry."""
+ client = await async_create_client(hass, entry.data)
+
+ # Validate the connection by listing models
+ try:
+ await async_list_models(client)
+ except HomeAssistantError as err:
+ if err.translation_key == "invalid_auth":
+ raise ConfigEntryAuthFailed(
+ translation_domain=err.translation_domain,
+ translation_key=err.translation_key,
+ translation_placeholders=err.translation_placeholders,
+ ) from err
+ raise ConfigEntryNotReady(
+ translation_domain=err.translation_domain,
+ translation_key=err.translation_key,
+ translation_placeholders=err.translation_placeholders,
+ ) from err
+
+ entry.runtime_data = client
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ entry.async_on_unload(entry.add_update_listener(async_update_options))
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool:
+ """Unload llama.cpp."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_update_options(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> None:
+ """Update options."""
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/llama_cpp/api.py b/homeassistant/components/llama_cpp/api.py
new file mode 100644
index 000000000000..bf2c7bfa3959
--- /dev/null
+++ b/homeassistant/components/llama_cpp/api.py
@@ -0,0 +1,192 @@
+"""API client helper for llama.cpp integration.
+
+This module contains thin wrappers around the OpenAI completions APIs used
+to simplify Home Assistant integration and configuration. It handles client
+setup, model validation, and API error handling.
+"""
+
+from collections.abc import Generator, Mapping
+from contextlib import contextmanager
+import logging
+from typing import Any, cast
+
+import openai
+from openai._streaming import AsyncStream
+from openai.types.chat import (
+ ChatCompletionChunk,
+ ChatCompletionMessageParam,
+ ChatCompletionToolParam,
+)
+
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.httpx_client import get_async_client
+
+from .const import (
+ CONF_BASE_URL,
+ DEFAULT_API_KEY,
+ DEFAULT_MODEL,
+ DOMAIN,
+ RECOMMENDED_CHAT_MODELS,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+# Simple prompt to test model basic chat completion capability. We send tools
+# to ensure the model and server correctly supports tool calling. We set a
+# minimal max_tokens to consume few resources.
+_TEST_MESSAGES: list[ChatCompletionMessageParam] = [
+ {"role": "system", "content": "You are a helpful assistant."},
+ {"role": "user", "content": "What is the capital of France?"},
+]
+_TEST_TOOLS: list[ChatCompletionToolParam] = [
+ {
+ "type": "function",
+ "function": {
+ "name": "test_function",
+ "description": "Test function.",
+ "parameters": {"type": "object", "properties": {}},
+ },
+ }
+]
+_TEST_MAX_TOKENS = 3
+
+
+async def async_create_client(
+ hass: HomeAssistant, config_entry_data: Mapping[str, Any]
+) -> openai.AsyncOpenAI:
+ """Create a new OpenAI client."""
+ api_key = config_entry_data.get(CONF_API_KEY) or DEFAULT_API_KEY
+ client = openai.AsyncOpenAI(
+ api_key=api_key,
+ base_url=config_entry_data[CONF_BASE_URL],
+ http_client=get_async_client(hass),
+ )
+ # Cache current platform data which gets added to each request
+ # (caching done by library)
+ _ = await hass.async_add_executor_job(client.platform_headers)
+ return client
+
+
+async def async_list_models(client: openai.AsyncOpenAI) -> list[str]:
+ """Return a list of models supported by the client."""
+ with api_error_handler():
+ page = await client.with_options(timeout=10.0).models.list()
+ return [model.id async for model in page]
+
+
+async def async_validate_completions(
+ client: openai.AsyncOpenAI,
+ model: str,
+ stream: bool = False,
+) -> None:
+ """Validate that we can speak to the model over the completions API."""
+ with api_error_handler():
+ result = await client.chat.completions.create(
+ model=model,
+ messages=_TEST_MESSAGES,
+ tools=_TEST_TOOLS,
+ max_tokens=_TEST_MAX_TOKENS,
+ stream=stream,
+ )
+
+ if stream:
+ stream_result = cast(AsyncStream[ChatCompletionChunk], result)
+ async for event in stream_result:
+ if not event.choices:
+ continue
+ if event.choices[0].finish_reason is not None:
+ continue
+
+
+def recommended_model(models: list[str] | None) -> str:
+ """Return the selected model from user input."""
+ if not models:
+ return DEFAULT_MODEL
+ for model in RECOMMENDED_CHAT_MODELS:
+ if model in models:
+ return model
+ return models[0]
+
+
+def model_name_to_title(model_id: str) -> str:
+ """Convert a model ID into a human-readable title (inverse slugification).
+
+ Examples:
+ - "deepseek-v4-flash" -> "Deepseek V4 Flash"
+ - "gpt-4" -> "Gpt 4"
+ - "llama-3.2-3b-instruct" -> "Llama 3.2 3b Instruct"
+ - "anthropic/claude-fable-5" -> "Anthropic Claude Fable 5"
+ """
+ words = model_id.replace("-", " ").replace("_", " ").replace("/", " ").split()
+ return " ".join(word.capitalize() for word in words)
+
+
+def _extract_error_message(err: openai.APIStatusError) -> str:
+ """Extract a clean error message from an APIStatusError response or message."""
+ error_message = ""
+ if err.response is not None:
+ try:
+ json_data = err.response.json()
+ if isinstance(json_data, dict) and "error" in json_data:
+ error_message = json_data["error"].get("message") or ""
+ except ValueError:
+ pass
+ return error_message or err.message or str(err)
+
+
+@contextmanager
+def api_error_handler() -> Generator[None]:
+ """Context manager to handle API errors and translate them to HomeAssistantErrors."""
+ try:
+ yield
+ except openai.APITimeoutError as err:
+ _LOGGER.error("Timeout talking to API: %s", err)
+ error_message = err.message or str(err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="timeout",
+ translation_placeholders={"message": error_message},
+ ) from err
+ except openai.APIConnectionError as err:
+ _LOGGER.error("Connection error talking to API: %s", err)
+ error_message = err.message or str(err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"message": error_message},
+ ) from err
+ except openai.AuthenticationError as err:
+ _LOGGER.error("Authentication error talking to API: %s", err)
+ error_message = _extract_error_message(err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={"message": error_message},
+ ) from err
+ except openai.APIStatusError as err:
+ _LOGGER.error("Status error talking to API: %s", err)
+ error_message = _extract_error_message(err)
+
+ if err.status_code == 402:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="quota_exceeded",
+ translation_placeholders={"message": error_message},
+ ) from err
+
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="api_error",
+ translation_placeholders={"message": error_message},
+ ) from err
+ except openai.OpenAIError as err:
+ _LOGGER.error("Generic error talking to API: %s", err)
+ error_message = getattr(err, "message", None) or str(err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="api_error",
+ translation_placeholders={"message": error_message},
+ ) from err
diff --git a/homeassistant/components/llama_cpp/config_flow.py b/homeassistant/components/llama_cpp/config_flow.py
new file mode 100644
index 000000000000..8c7e42c29ed5
--- /dev/null
+++ b/homeassistant/components/llama_cpp/config_flow.py
@@ -0,0 +1,383 @@
+"""Config flow for llama.cpp integration."""
+
+import logging
+from typing import Any, cast, override
+
+import openai
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigEntryState,
+ ConfigFlow,
+ ConfigFlowResult,
+ ConfigSubentryFlow,
+ SubentryFlowResult,
+)
+from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import llm
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+ TemplateSelector,
+)
+
+from .api import (
+ async_create_client,
+ async_list_models,
+ async_validate_completions,
+ model_name_to_title,
+ recommended_model,
+)
+from .const import (
+ CONF_BASE_URL,
+ CONF_CHAT_MODEL,
+ CONF_MAX_TOKENS,
+ CONF_RECOMMENDED,
+ CONF_STREAMING,
+ CONF_TEMPERATURE,
+ CONF_TOP_P,
+ DEFAULT_BASE_URL,
+ DOMAIN,
+ LOGGER,
+ RECOMMENDED_MAX_TOKENS,
+ RECOMMENDED_TEMPERATURE,
+ RECOMMENDED_TOP_P,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str,
+ vol.Optional(CONF_API_KEY): str,
+ }
+)
+
+
+class LlamaCppConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for llama.cpp."""
+
+ VERSION = 1
+
+ data: dict[str, Any] | None = None
+ client: openai.AsyncOpenAI | None = None
+ models: list[str] | None = None
+
+ @override
+ 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:
+ self._async_abort_entries_match(user_input)
+ try:
+ self.client = await async_create_client(self.hass, user_input)
+ self.models = await async_list_models(self.client)
+ except HomeAssistantError as err:
+ LOGGER.error("Connection validation failed: %s", err)
+ errors["base"] = err.translation_key or "unknown"
+ except Exception: # pylint: disable=broad-except # noqa: BLE001
+ LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ self.data = user_input
+ return await self.async_step_model()
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ )
+
+ async def async_step_model(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle selecting a model."""
+ assert self.client is not None
+ assert self.models is not None
+ assert self.data is not None
+ errors = {}
+ if user_input is not None:
+ model = user_input[CONF_CHAT_MODEL]
+ try:
+ await async_validate_completions(
+ self.client,
+ model=model,
+ stream=False,
+ )
+ except HomeAssistantError as err:
+ LOGGER.error("Model completion validation failed: %s", err)
+ errors["base"] = err.translation_key or "unknown"
+ else:
+ stream_support = True
+ try:
+ await async_validate_completions(
+ self.client,
+ model=model,
+ stream=True,
+ )
+ except HomeAssistantError:
+ stream_support = False
+
+ base_options = {
+ **user_input,
+ }
+ return self.async_create_entry(
+ title=self.data[CONF_BASE_URL],
+ data={
+ **self.data,
+ CONF_STREAMING: stream_support,
+ },
+ subentries=[
+ {
+ "subentry_type": "conversation",
+ "data": {
+ CONF_RECOMMENDED: True,
+ CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
+ **base_options,
+ },
+ "title": model_name_to_title(model),
+ "unique_id": None,
+ },
+ ],
+ )
+
+ return self.async_show_form(
+ step_id="model",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Optional(
+ CONF_CHAT_MODEL,
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=self.models,
+ translation_key=CONF_CHAT_MODEL,
+ mode=SelectSelectorMode.DROPDOWN,
+ custom_value=True,
+ ),
+ ),
+ }
+ ),
+ {
+ CONF_CHAT_MODEL: (user_input or {}).get(
+ CONF_CHAT_MODEL, recommended_model(self.models)
+ ),
+ },
+ ),
+ errors=errors,
+ )
+
+ @classmethod
+ @callback
+ @override
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this integration."""
+ return {
+ "conversation": ConversationSubentryFlowHandler,
+ }
+
+
+class ConversationSubentryFlowHandler(ConfigSubentryFlow):
+ """Flow for managing conversation subentries."""
+
+ last_rendered_recommended = False
+ options: dict[str, Any] | None = None
+ models: list[str] | None = None
+
+ @property
+ def _openai_client(self) -> openai.AsyncOpenAI:
+ """Return the OpenAI client."""
+ return cast(openai.AsyncOpenAI, self._get_entry().runtime_data)
+
+ async def _get_models(self) -> list[str] | None:
+ """Return the list of models."""
+ if self.models is None:
+ self.models = await async_list_models(self._openai_client)
+ return self.models
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Add a subentry."""
+ if self._get_entry().state is not ConfigEntryState.LOADED:
+ return self.async_abort(reason="entry_not_loaded")
+
+ try:
+ models = await self._get_models()
+ except HomeAssistantError:
+ return self.async_abort(reason="cannot_connect")
+ self.options = {
+ CONF_RECOMMENDED: True,
+ CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
+ CONF_CHAT_MODEL: recommended_model(models),
+ }
+ self.last_rendered_recommended = cast(
+ bool, self.options.get(CONF_RECOMMENDED, False)
+ )
+ return await self.async_step_init()
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Handle reconfiguration of a subentry."""
+ return await self.async_step_init()
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Manage initial options."""
+ # abort if entry is not loaded
+ if self._get_entry().state is not ConfigEntryState.LOADED:
+ return self.async_abort(reason="entry_not_loaded")
+
+ if self.options is None:
+ self.options = self._get_reconfigure_subentry().data.copy()
+ self.last_rendered_recommended = cast(
+ bool, self.options.get(CONF_RECOMMENDED, False)
+ )
+
+ try:
+ models = await self._get_models()
+ except HomeAssistantError:
+ return self.async_abort(reason="cannot_connect")
+
+ options = self.options
+
+ if user_input is not None:
+ model = user_input[CONF_CHAT_MODEL]
+ try:
+ await async_validate_completions(
+ self._openai_client,
+ model=model,
+ stream=self._get_entry().data.get(CONF_STREAMING, False),
+ )
+ except HomeAssistantError as err:
+ LOGGER.error("Model completion validation failed: %s", err)
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ llama_cpp_config_option_schema(self.hass, options, models)
+ ),
+ user_input,
+ ),
+ errors={"base": err.translation_key or "unknown"},
+ )
+
+ if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
+ if self.source == "user":
+ return self.async_create_entry(
+ title=model_name_to_title(user_input[CONF_CHAT_MODEL]),
+ data=user_input,
+ )
+ return self.async_update_and_abort(
+ self._get_entry(),
+ self._get_reconfigure_subentry(),
+ data=user_input,
+ title=model_name_to_title(user_input[CONF_CHAT_MODEL]),
+ )
+
+ self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
+
+ options = {
+ CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
+ CONF_PROMPT: user_input[CONF_PROMPT],
+ CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL],
+ CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API, []),
+ }
+
+ schema = llama_cpp_config_option_schema(self.hass, options, models)
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(schema), options
+ ),
+ )
+
+
+def llama_cpp_config_option_schema(
+ hass: HomeAssistant,
+ options: dict[str, Any],
+ models: list[str] | None = None,
+) -> dict:
+ """Return a schema for llama.cpp completion options."""
+ hass_apis: list[SelectOptionDict] = [
+ SelectOptionDict(
+ label=api.name,
+ value=api.id,
+ )
+ for api in llm.async_get_apis(hass)
+ ]
+ LOGGER.debug("Available LLM APIs: %s", hass_apis)
+
+ schema: dict[vol.Required | vol.Optional, Any] = {}
+
+ schema.update(
+ {
+ vol.Optional(
+ CONF_PROMPT,
+ description={
+ "suggested_value": options.get(
+ CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
+ )
+ },
+ ): TemplateSelector(),
+ vol.Optional(
+ CONF_LLM_HASS_API,
+ ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
+ }
+ )
+ schema.update(
+ {
+ vol.Optional(
+ CONF_CHAT_MODEL,
+ description={"suggested_value": options.get(CONF_CHAT_MODEL)},
+ default=options.get(CONF_CHAT_MODEL, recommended_model(models)),
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=models or [],
+ translation_key=CONF_CHAT_MODEL,
+ mode=SelectSelectorMode.DROPDOWN,
+ custom_value=True,
+ ),
+ ),
+ vol.Required(
+ CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
+ ): bool,
+ }
+ )
+
+ if options.get(CONF_RECOMMENDED):
+ return schema
+
+ schema.update(
+ {
+ vol.Optional(
+ CONF_MAX_TOKENS,
+ description={"suggested_value": options.get(CONF_MAX_TOKENS)},
+ default=RECOMMENDED_MAX_TOKENS,
+ ): int,
+ vol.Optional(
+ CONF_TOP_P,
+ description={"suggested_value": options.get(CONF_TOP_P)},
+ default=RECOMMENDED_TOP_P,
+ ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
+ vol.Optional(
+ CONF_TEMPERATURE,
+ description={"suggested_value": options.get(CONF_TEMPERATURE)},
+ default=RECOMMENDED_TEMPERATURE,
+ ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
+ }
+ )
+ return schema
diff --git a/homeassistant/components/llama_cpp/const.py b/homeassistant/components/llama_cpp/const.py
new file mode 100644
index 000000000000..401d2a99abc3
--- /dev/null
+++ b/homeassistant/components/llama_cpp/const.py
@@ -0,0 +1,30 @@
+"""Constants for the llama.cpp integration."""
+
+import logging
+
+DOMAIN = "llama_cpp"
+LOGGER = logging.getLogger(__package__)
+
+DEFAULT_CONVERSATION_NAME = "llama.cpp Conversation"
+
+CONF_CHAT_MODEL = "chat_model"
+CONF_MAX_TOKENS = "max_tokens"
+CONF_TEMPERATURE = "temperature"
+CONF_TOP_P = "top_p"
+CONF_BASE_URL = "base_url"
+CONF_RECOMMENDED = "recommended"
+CONF_STREAMING = "streaming"
+
+# Some servers set placeholder model names which we can use as a default
+DEFAULT_MODEL = "gpt-3.5-turbo"
+RECOMMENDED_CHAT_MODELS = [
+ DEFAULT_MODEL,
+ "gpt-4",
+ "local-model",
+]
+RECOMMENDED_MAX_TOKENS = 3000
+RECOMMENDED_TEMPERATURE = 0.7
+RECOMMENDED_TOP_P = 1.0
+
+DEFAULT_BASE_URL = "http://localhost:8080/v1"
+DEFAULT_API_KEY = "sk-0000000000000000000"
diff --git a/homeassistant/components/llama_cpp/conversation.py b/homeassistant/components/llama_cpp/conversation.py
new file mode 100644
index 000000000000..44ff4c07d7fe
--- /dev/null
+++ b/homeassistant/components/llama_cpp/conversation.py
@@ -0,0 +1,83 @@
+"""Conversation support for llama.cpp."""
+
+from typing import Literal, override
+
+from homeassistant.components import conversation
+from homeassistant.config_entries import ConfigEntry, ConfigSubentry
+from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import LlamaCppConfigEntry
+from .const import DOMAIN
+from .entity import LlamaCppBaseLLMEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: LlamaCppConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up conversation entities."""
+ for subentry in config_entry.subentries.values():
+ async_add_entities(
+ [LlamaCppConversationEntity(config_entry, subentry)],
+ config_subentry_id=subentry.subentry_id,
+ )
+
+
+class LlamaCppConversationEntity(
+ conversation.ConversationEntity,
+ conversation.AbstractConversationAgent,
+ LlamaCppBaseLLMEntity,
+):
+ """llama.cpp conversation agent."""
+
+ def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
+ """Initialize the agent."""
+ super().__init__(entry, subentry)
+ if self.subentry.data.get(CONF_LLM_HASS_API):
+ self._attr_supported_features = (
+ conversation.ConversationEntityFeature.CONTROL
+ )
+
+ @property
+ @override
+ def supported_languages(self) -> list[str] | Literal["*"]:
+ """Return a list of supported languages."""
+ return MATCH_ALL
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to Home Assistant."""
+ await super().async_added_to_hass()
+ conversation.async_set_agent(self.hass, self.entry, self)
+
+ @override
+ async def async_will_remove_from_hass(self) -> None:
+ """When entity will be removed from Home Assistant."""
+ conversation.async_unset_agent(self.hass, self.entry)
+ await super().async_will_remove_from_hass()
+
+ @override
+ async def _async_handle_message(
+ self,
+ user_input: conversation.ConversationInput,
+ chat_log: conversation.ChatLog,
+ ) -> conversation.ConversationResult:
+ """Process a sentence."""
+ options = self.subentry.data
+
+ try:
+ await chat_log.async_provide_llm_data(
+ user_input.as_llm_context(DOMAIN),
+ options.get(CONF_LLM_HASS_API),
+ options.get(CONF_PROMPT),
+ user_input.extra_system_prompt,
+ )
+ except conversation.ConverseError as err:
+ return err.as_conversation_result()
+
+ await self._async_handle_chat_log(chat_log)
+
+ return conversation.async_get_result_from_chat_log(user_input, chat_log)
diff --git a/homeassistant/components/llama_cpp/entity.py b/homeassistant/components/llama_cpp/entity.py
new file mode 100644
index 000000000000..605c9ffb9793
--- /dev/null
+++ b/homeassistant/components/llama_cpp/entity.py
@@ -0,0 +1,457 @@
+"""Base entity for llama.cpp Conversation."""
+
+import base64
+from collections.abc import AsyncGenerator, Callable
+import json
+import logging
+import mimetypes
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Literal, cast
+
+from openai import AsyncOpenAI
+from openai._streaming import AsyncStream
+from openai._types import Omit
+from openai.types.chat import (
+ ChatCompletion,
+ ChatCompletionAssistantMessageParam,
+ ChatCompletionChunk,
+ ChatCompletionContentPartParam,
+ ChatCompletionContentPartTextParam,
+ ChatCompletionFunctionToolParam,
+ ChatCompletionMessage,
+ ChatCompletionMessageFunctionToolCall,
+ ChatCompletionMessageParam,
+ ChatCompletionMessageToolCallParam,
+ ChatCompletionSystemMessageParam,
+ ChatCompletionToolMessageParam,
+ ChatCompletionUserMessageParam,
+)
+from openai.types.chat.chat_completion_message_function_tool_call_param import Function
+from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema
+import voluptuous as vol
+from voluptuous_openapi import convert
+
+from homeassistant.components import conversation
+from homeassistant.config_entries import ConfigSubentry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr, llm
+from homeassistant.helpers.entity import Entity
+
+from .api import api_error_handler
+from .const import (
+ CONF_CHAT_MODEL,
+ CONF_MAX_TOKENS,
+ CONF_STREAMING,
+ CONF_TEMPERATURE,
+ CONF_TOP_P,
+ DEFAULT_MODEL,
+ DOMAIN,
+ LOGGER,
+ RECOMMENDED_MAX_TOKENS,
+ RECOMMENDED_TEMPERATURE,
+ RECOMMENDED_TOP_P,
+)
+
+if TYPE_CHECKING:
+ from . import LlamaCppConfigEntry
+
+# Max number of back and forth with the LLM to generate a response
+MAX_TOOL_ITERATIONS = 10
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _format_structured_output(
+ name: str, structure: vol.Schema, llm_api: llm.APIInstance | None
+) -> ResponseFormatJSONSchema:
+ """Format structured output specification."""
+ schema = convert(
+ structure, custom_serializer=llm_api.custom_serializer if llm_api else None
+ )
+ return ResponseFormatJSONSchema(
+ type="json_schema",
+ json_schema={
+ "name": name,
+ "strict": True,
+ "schema": cast(dict[str, object], schema),
+ },
+ )
+
+
+def _format_tool(
+ tool: llm.Tool,
+ custom_serializer: Callable[[Any], Any] | None,
+) -> ChatCompletionFunctionToolParam:
+ """Format tool specification."""
+ tool_spec = FunctionDefinition(
+ name=tool.name,
+ parameters=convert(tool.parameters, custom_serializer=custom_serializer),
+ )
+ if tool.description:
+ tool_spec["description"] = tool.description
+ return ChatCompletionFunctionToolParam(type="function", function=tool_spec)
+
+
+def _convert_content_to_chat_message(
+ content: conversation.Content,
+) -> ChatCompletionMessageParam | None:
+ """Convert any native chat message for this agent to the native format."""
+ _LOGGER.debug("_convert_content_to_chat_message=%s", content)
+ if isinstance(content, conversation.ToolResultContent):
+ return ChatCompletionToolMessageParam(
+ role="tool",
+ tool_call_id=content.tool_call_id,
+ content=json.dumps(content.tool_result),
+ )
+
+ role: Literal["user", "assistant", "system"] = content.role
+ if role == "system" and content.content:
+ return ChatCompletionSystemMessageParam(role="system", content=content.content)
+
+ if role == "user" and content.content:
+ return ChatCompletionUserMessageParam(role="user", content=content.content)
+
+ if role == "assistant":
+ param = ChatCompletionAssistantMessageParam(
+ role="assistant",
+ content=content.content,
+ )
+ if isinstance(content, conversation.AssistantContent) and content.tool_calls:
+ param["tool_calls"] = [
+ ChatCompletionMessageToolCallParam(
+ type="function",
+ id=tool_call.id,
+ function=Function(
+ arguments=json.dumps(tool_call.tool_args),
+ name=tool_call.tool_name,
+ ),
+ )
+ for tool_call in content.tool_calls
+ ]
+ return param
+ LOGGER.warning("Could not convert message to OpenAI API: %s", content)
+ return None
+
+
+def _decode_tool_arguments(arguments: str) -> Any:
+ """Decode tool call arguments."""
+ try:
+ return json.loads(arguments)
+ except json.JSONDecodeError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="json_parse_error",
+ translation_placeholders={"message": str(err)},
+ ) from err
+
+
+async def _transform_response(
+ message: ChatCompletionMessage,
+) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
+ """Transform the OpenAI API message to a ChatLog format."""
+ data: conversation.AssistantContentDeltaDict = {
+ "role": message.role,
+ "content": message.content,
+ }
+ if message.tool_calls:
+ data["tool_calls"] = [
+ llm.ToolInput(
+ id=tool_call.id,
+ tool_name=tool_call.function.name,
+ tool_args=_decode_tool_arguments(tool_call.function.arguments),
+ )
+ for tool_call in message.tool_calls
+ if isinstance(tool_call, ChatCompletionMessageFunctionToolCall)
+ ]
+ yield data
+
+
+def _convert_content_to_param(
+ content: conversation.Content,
+) -> ChatCompletionMessageParam:
+ """Convert any native chat message for this agent to the native format."""
+ if isinstance(content, conversation.ToolResultContent):
+ return ChatCompletionToolMessageParam(
+ role="tool",
+ tool_call_id=content.tool_call_id,
+ content=json.dumps(content.tool_result),
+ )
+ if not isinstance(content, conversation.AssistantContent) or not content.tool_calls:
+ if isinstance(content, conversation.SystemContent):
+ return ChatCompletionSystemMessageParam(
+ role="system",
+ content=content.content or "",
+ )
+ return cast(
+ ChatCompletionMessageParam,
+ {"role": content.role, "content": content.content or ""},
+ )
+
+ 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",
+ )
+ for tool_call in content.tool_calls
+ ],
+ )
+
+
+async def _transform_stream(
+ result: AsyncStream[ChatCompletionChunk],
+) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
+ """Transform an OpenAI delta stream into HA format."""
+ current_tool_call: dict[str, Any] | None = None
+ yielded_role = False
+
+ async for chunk in result:
+ LOGGER.debug("Received chunk: %s", chunk)
+ if not chunk.choices:
+ continue
+ 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=_decode_tool_arguments(
+ current_tool_call["tool_args"]
+ )
+ if current_tool_call["tool_args"]
+ else {},
+ )
+ ]
+ }
+ break
+
+ delta = choice.delta
+
+ if current_tool_call is None and not delta.tool_calls:
+ yield_dict: conversation.AssistantContentDeltaDict = {}
+ if not yielded_role and delta.role == "assistant":
+ yield_dict["role"] = "assistant"
+ yielded_role = True
+ if delta.content is not None:
+ yield_dict["content"] = delta.content
+ if yield_dict:
+ yield yield_dict
+ continue
+
+ if (
+ not delta.tool_calls
+ or not (delta_tool_call := delta.tool_calls[0])
+ or not delta_tool_call.function
+ ):
+ continue
+
+ 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
+
+ if current_tool_call:
+ yield {
+ "tool_calls": [
+ llm.ToolInput(
+ id=current_tool_call["id"],
+ tool_name=current_tool_call["tool_name"],
+ tool_args=_decode_tool_arguments(
+ current_tool_call["tool_args"]
+ ),
+ )
+ ]
+ }
+
+ 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 "",
+ }
+
+
+class LlamaCppBaseLLMEntity(Entity):
+ """llama.cpp base LLM entity."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+
+ def __init__(self, entry: LlamaCppConfigEntry, subentry: ConfigSubentry) -> None:
+ """Initialize the entity."""
+ self.entry = entry
+ self.subentry = subentry
+ self._attr_unique_id = subentry.subentry_id
+ self._attr_device_info = dr.DeviceInfo(
+ identifiers={(DOMAIN, subentry.subentry_id)},
+ name=subentry.title,
+ manufacturer="llama.cpp",
+ model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT_MODEL),
+ entry_type=dr.DeviceEntryType.SERVICE,
+ )
+
+ async def _async_handle_chat_log(
+ self,
+ chat_log: conversation.ChatLog,
+ structure_name: str | None = None,
+ structure: vol.Schema | None = None,
+ ) -> None:
+ """Generate an answer for the chat log."""
+ options = self.subentry.data
+
+ tools: list[ChatCompletionFunctionToolParam] | 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
+ ]
+
+ model: str = options.get(CONF_CHAT_MODEL, DEFAULT_MODEL)
+ messages = [
+ m
+ for content in chat_log.content
+ if (m := _convert_content_to_chat_message(content))
+ ]
+
+ response_format: ResponseFormatJSONSchema | Omit = Omit()
+ if structure and structure_name:
+ response_format = _format_structured_output(
+ structure_name, structure, chat_log.llm_api
+ )
+
+ last_content = chat_log.content[-1]
+ if (
+ isinstance(last_content, conversation.UserContent)
+ and last_content.attachments
+ ):
+ files = await async_prepare_files_for_prompt(
+ self.hass,
+ [a.path for a in last_content.attachments],
+ )
+ for i in range(len(messages) - 1, -1, -1):
+ if messages[i]["role"] == "user":
+ user_msg = cast(ChatCompletionUserMessageParam, messages[i])
+ current_content = user_msg.get("content")
+ if isinstance(current_content, str):
+ user_msg["content"] = [
+ ChatCompletionContentPartTextParam(
+ type="text", text=current_content
+ ),
+ *files,
+ ]
+ break
+
+ client: AsyncOpenAI = self.entry.runtime_data
+ streaming = bool(
+ self.entry.data.get(CONF_STREAMING, options.get(CONF_STREAMING, False))
+ )
+
+ for _iteration in range(MAX_TOOL_ITERATIONS):
+ with api_error_handler():
+ result = await client.chat.completions.create(
+ messages=messages,
+ model=model,
+ tools=tools or Omit(),
+ response_format=response_format,
+ max_tokens=cast(
+ int, options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS)
+ ),
+ top_p=cast(float, options.get(CONF_TOP_P, RECOMMENDED_TOP_P)),
+ temperature=cast(
+ float, options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE)
+ ),
+ user=chat_log.conversation_id,
+ stream=cast(Any, streaming),
+ )
+
+ convert_message: Callable[[Any], Any]
+ async_generator: AsyncGenerator[conversation.AssistantContentDeltaDict]
+ if streaming:
+ convert_message = _convert_content_to_param
+ async_generator = _transform_stream(
+ cast(AsyncStream[ChatCompletionChunk], result)
+ )
+ else:
+ convert_message = _convert_content_to_chat_message
+ async_generator = _transform_response(
+ cast(ChatCompletion, result).choices[0].message
+ )
+
+ messages.extend(
+ [
+ msg
+ async for content in chat_log.async_add_delta_content_stream(
+ self.entity_id, async_generator
+ )
+ if (msg := convert_message(content))
+ ]
+ )
+
+ if not chat_log.unresponded_tool_results:
+ break
+
+
+async def async_prepare_files_for_prompt(
+ hass: HomeAssistant, files: list[Path]
+) -> list[ChatCompletionContentPartParam]:
+ """Prepare files for OpenAI-compatible API.
+
+ Caller needs to ensure that the files are allowed.
+ """
+
+ def guess_file_type(file_path: Path) -> tuple[str | None, str | None]:
+ """Guess the file type based on the file extension."""
+ return mimetypes.guess_type(str(file_path))
+
+ def append_files_to_content() -> list[ChatCompletionContentPartParam]:
+ content: list[ChatCompletionContentPartParam] = []
+
+ for file_path in files:
+ if not file_path.exists():
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="file_not_found",
+ translation_placeholders={"file_path": str(file_path)},
+ )
+
+ mime_type, _ = guess_file_type(file_path)
+
+ if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unsupported_file_type",
+ translation_placeholders={"file_path": str(file_path)},
+ )
+
+ base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
+
+ if mime_type.startswith("image/"):
+ content.append(
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": f"data:{mime_type};base64,{base64_file}",
+ "detail": "auto",
+ },
+ }
+ )
+ elif mime_type.startswith("application/pdf"):
+ content.append(
+ {
+ "type": "text",
+ "text": f"[File: {file_path.name}]\nContent: {base64_file}",
+ }
+ )
+
+ return content
+
+ return await hass.async_add_executor_job(append_files_to_content)
diff --git a/homeassistant/components/llama_cpp/manifest.json b/homeassistant/components/llama_cpp/manifest.json
new file mode 100644
index 000000000000..a610d9027208
--- /dev/null
+++ b/homeassistant/components/llama_cpp/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "llama_cpp",
+ "name": "llama.cpp",
+ "after_dependencies": ["assist_pipeline", "intent"],
+ "codeowners": ["@allenporter"],
+ "config_flow": true,
+ "dependencies": ["conversation"],
+ "documentation": "https://www.home-assistant.io/integrations/llama_cpp",
+ "integration_type": "service",
+ "iot_class": "local_polling",
+ "quality_scale": "bronze",
+ "requirements": ["openai==2.45.0"]
+}
diff --git a/homeassistant/components/llama_cpp/quality_scale.yaml b/homeassistant/components/llama_cpp/quality_scale.yaml
new file mode 100644
index 000000000000..672cef1fdef1
--- /dev/null
+++ b/homeassistant/components/llama_cpp/quality_scale.yaml
@@ -0,0 +1,104 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: No service actions are registered by this integration.
+ appropriate-polling:
+ status: exempt
+ comment: The integration does not poll and is push-based.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: No service actions are registered by this integration.
+ docs-conditions:
+ status: exempt
+ comment: No custom conditions are supported by this integration.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: No custom triggers are supported by this integration.
+ entity-event-setup:
+ status: exempt
+ comment: No event entities or helper events are supported by this integration.
+ 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 service actions are registered by this integration.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: Conversation entities do not have an unavailable state.
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: Conversation entities do not have an unavailable state.
+ parallel-updates:
+ status: exempt
+ comment: No periodic updates are performed by this integration.
+ reauthentication-flow: todo
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: The integration does not support discovery.
+ discovery:
+ status: exempt
+ comment: The integration does not support discovery.
+ docs-data-update:
+ status: exempt
+ comment: No periodic data updates are performed by this integration.
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: The integration does not support physical devices.
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: No physical devices are supported.
+ entity-category:
+ status: exempt
+ comment: Conversation entity does not require an entity category.
+ entity-device-class:
+ status: exempt
+ comment: Conversation entity does not require a device class.
+ entity-disabled-by-default:
+ status: exempt
+ comment: Conversation entity should be enabled by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: No icons are defined for this integration.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: No repair issues are defined for this integration.
+ stale-devices:
+ status: exempt
+ comment: No physical devices are supported.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/llama_cpp/strings.json b/homeassistant/components/llama_cpp/strings.json
new file mode 100644
index 000000000000..d298773a914e
--- /dev/null
+++ b/homeassistant/components/llama_cpp/strings.json
@@ -0,0 +1,98 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ },
+ "error": {
+ "api_error": "[%key:common::config_flow::error::unknown%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "quota_exceeded": "Your account or API key has insufficient credits.",
+ "timeout": "Connection timed out.",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "step": {
+ "model": {
+ "data": {
+ "chat_model": "[%key:common::generic::model%]"
+ },
+ "data_description": {
+ "chat_model": "Select the model to use."
+ }
+ },
+ "user": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]",
+ "base_url": "URL"
+ },
+ "data_description": {
+ "api_key": "API key for the server (optional).",
+ "base_url": "Base URL of your running OpenAI-compatible server (e.g. http://localhost:8080/v1)."
+ }
+ }
+ }
+ },
+ "config_subentries": {
+ "conversation": {
+ "abort": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "entry_not_loaded": "Cannot add things while the configuration is disabled.",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ },
+ "entry_type": "Conversation agent",
+ "initiate_flow": {
+ "user": "Add conversation agent"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "chat_model": "[%key:common::generic::model%]",
+ "llm_hass_api": "Control Home Assistant",
+ "max_tokens": "Maximum tokens to return in response",
+ "name": "[%key:common::config_flow::data::name%]",
+ "prompt": "Instructions",
+ "recommended": "Recommended model settings",
+ "temperature": "Temperature",
+ "top_p": "Top P"
+ },
+ "data_description": {
+ "chat_model": "Select the model to use.",
+ "llm_hass_api": "Select the level of control over Home Assistant.",
+ "max_tokens": "Select the maximum number of tokens to return.",
+ "prompt": "Instruct how the LLM should respond. This can be a template.",
+ "recommended": "Select whether to use recommended model settings.",
+ "temperature": "Select the temperature for response variability.",
+ "top_p": "Select the top P value for response diversity."
+ }
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "api_error": {
+ "message": "API error: {message}."
+ },
+ "cannot_connect": {
+ "message": "Cannot connect to the server: {message}."
+ },
+ "file_not_found": {
+ "message": "File does not exist: {file_path}."
+ },
+ "invalid_auth": {
+ "message": "Invalid authentication: {message}."
+ },
+ "json_parse_error": {
+ "message": "Unexpected tool argument response: {message}."
+ },
+ "quota_exceeded": {
+ "message": "Your account or API key has insufficient credits: {message}."
+ },
+ "timeout": {
+ "message": "Connection timed out: {message}."
+ },
+ "unsupported_file_type": {
+ "message": "Only images and PDF are supported by the OpenAI API, {file_path} is not an image file or PDF."
+ }
+ }
+}
diff --git a/homeassistant/components/llm/__init__.py b/homeassistant/components/llm/__init__.py
index 0fdd1783e0e2..7b907762f89c 100644
--- a/homeassistant/components/llm/__init__.py
+++ b/homeassistant/components/llm/__init__.py
@@ -1,19 +1,21 @@
-"""The LLM integration.
-
-Owns the LLM tools platform: integrations contribute tools to the LLM APIs
-through an ``/llm.py`` platform with an ``async_get_tools`` hook.
-The platforms are loaded lazily and queried per request. The framework
-(``Tool``, the APIs) lives in ``homeassistant.helpers.llm``.
-"""
+"""The LLM integration."""
from dataclasses import dataclass
import logging
-from typing import Protocol
+from typing import Protocol, override
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms
-from homeassistant.helpers.llm import LLMContext, Tool
+from homeassistant.helpers.llm import (
+ API,
+ LLM_API_ASSIST,
+ APIInstance,
+ LLMContext,
+ Tool,
+ async_register_api,
+ selector_serializer,
+)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -40,8 +42,13 @@ class LLMToolsPlatformProtocol(Protocol):
"""Define the format that LLM tools platforms can have."""
@callback
- def async_get_tools(self, hass: HomeAssistant, llm_context: LLMContext) -> LLMTools:
- """Return the integration's LLM tools for the given context."""
+ def async_get_tools(
+ self, hass: HomeAssistant, llm_context: LLMContext, api_id: str
+ ) -> LLMTools | None:
+ """Return the integration's LLM tools for the given context and API.
+
+ Return None when the integration has nothing for the given API.
+ """
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -49,6 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_PLATFORMS] = LazyIntegrationPlatforms(
hass, DOMAIN, _process_llm_tools_platform
)
+ async_register_api(hass, AssistAPI(hass))
return True
@@ -60,7 +68,9 @@ def _process_llm_tools_platform(
return platform
-async def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools:
+async def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools:
"""Return the tools and merged prompt from all integration platforms."""
platforms = await hass.data[DATA_PLATFORMS].async_get_platforms()
@@ -69,11 +79,38 @@ async def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTo
# Sort by domain so the tool and prompt order is independent of load order.
for domain, platform in sorted(platforms.items()):
try:
- result = platform.async_get_tools(hass, llm_context)
+ result = platform.async_get_tools(hass, llm_context, api_id)
except Exception:
_LOGGER.exception("Error getting tools from LLM platform %s", domain)
continue
+ if result is None:
+ continue
tools.extend(result.tools)
if result.prompt:
prompts.append(result.prompt)
return LLMTools(tools=tools, prompt="\n".join(prompts) if prompts else None)
+
+
+class AssistAPI(API):
+ """API exposing Assist API to LLMs."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Init the class."""
+ super().__init__(
+ hass=hass,
+ id=LLM_API_ASSIST,
+ name="Assist",
+ )
+
+ @override
+ async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance:
+ """Return the instance of the API."""
+ llm_tools = await async_get_tools(self.hass, llm_context, self.id)
+
+ return APIInstance(
+ api=self,
+ api_prompt=llm_tools.prompt or "",
+ llm_context=llm_context,
+ tools=llm_tools.tools,
+ custom_serializer=selector_serializer,
+ )
diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py
new file mode 100644
index 000000000000..c63f4098c245
--- /dev/null
+++ b/homeassistant/components/llm/llm.py
@@ -0,0 +1,45 @@
+"""LLM tools provided by the llm integration."""
+
+from typing import override
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.llm import LLMContext, Tool, ToolInput
+from homeassistant.util import dt as dt_util
+from homeassistant.util.json import JsonObjectType
+
+from . import LLMTools
+
+
+class GetDateTimeTool(Tool):
+ """Tool for getting the current date and time."""
+
+ name = "GetDateTime"
+ description = "Provides the current date and time."
+
+ @override
+ async def async_call(
+ self,
+ hass: HomeAssistant,
+ tool_input: ToolInput,
+ llm_context: LLMContext,
+ ) -> JsonObjectType:
+ """Get the current date and time."""
+ now = dt_util.now()
+
+ return {
+ "success": True,
+ "result": {
+ "date": now.strftime("%Y-%m-%d"),
+ "time": now.strftime("%H:%M:%S"),
+ "timezone": now.strftime("%Z"),
+ "weekday": now.strftime("%A"),
+ },
+ }
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools:
+ """Return the always-available LLM tools."""
+ return LLMTools(tools=[GetDateTimeTool()])
diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py
index bd5950b46beb..73ecf37efd41 100644
--- a/homeassistant/components/lookin/__init__.py
+++ b/homeassistant/components/lookin/__init__.py
@@ -1,4 +1,5 @@
"""The lookin integration."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import asyncio
from collections.abc import Callable, Coroutine
diff --git a/homeassistant/components/luci/__init__.py b/homeassistant/components/luci/__init__.py
index b0efa61ae778..f39ef47578c6 100644
--- a/homeassistant/components/luci/__init__.py
+++ b/homeassistant/components/luci/__init__.py
@@ -1 +1,59 @@
"""The luci component."""
+
+from openwrt_luci_rpc import OpenWrtRpc
+from requests.exceptions import ConnectionError as RequestsConnectionError
+
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_SSL,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+
+from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, PLATFORMS
+from .coordinator import LuciConfigEntry, LuciCoordinator
+
+
+def _connect(
+ host: str, username: str, password: str, ssl: bool, verify_ssl: bool
+) -> OpenWrtRpc:
+ """Connect to the router and verify login."""
+ router = OpenWrtRpc(host, username, password, ssl, verify_ssl)
+ if not router.is_logged_in():
+ raise ConfigEntryAuthFailed("Invalid credentials for router")
+ return router
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: LuciConfigEntry) -> bool:
+ """Set up OpenWrt (luci) from a config entry."""
+ try:
+ router = await hass.async_add_executor_job(
+ _connect,
+ entry.data[CONF_HOST],
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_PASSWORD],
+ entry.data.get(CONF_SSL, DEFAULT_SSL),
+ entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
+ )
+ except (ConnectionError, RequestsConnectionError) as err:
+ raise ConfigEntryNotReady(
+ f"Cannot connect to router at {entry.data[CONF_HOST]}"
+ ) from err
+
+ coordinator = LuciCoordinator(hass, entry, router)
+
+ 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: LuciConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/luci/config_flow.py b/homeassistant/components/luci/config_flow.py
new file mode 100644
index 000000000000..c5c8a191ab1f
--- /dev/null
+++ b/homeassistant/components/luci/config_flow.py
@@ -0,0 +1,139 @@
+"""Config flow for the OpenWrt (luci) integration."""
+
+from collections.abc import Mapping
+from typing import Any, override
+
+from openwrt_luci_rpc import OpenWrtRpc
+from requests.exceptions import ConnectionError as RequestsConnectionError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_SSL,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
+
+from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+ }
+)
+
+
+class InvalidAuth(Exception):
+ """Raised when authentication fails."""
+
+
+class LuciConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for OpenWrt (luci)."""
+
+ VERSION = 1
+
+ @override
+ 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:
+ self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+
+ try:
+ await self.hass.async_add_executor_job(_try_connect, user_input)
+ except ConnectionError, RequestsConnectionError:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ else:
+ return self.async_create_entry(
+ title=user_input[CONF_HOST],
+ data=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle reauthentication."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reauthentication confirmation."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+
+ if user_input is not None:
+ try:
+ await self.hass.async_add_executor_job(
+ _try_connect, reauth_entry.data | user_input
+ )
+ except ConnectionError, RequestsConnectionError:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ 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=vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
+ """Handle import from legacy YAML configuration."""
+ self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
+
+ try:
+ await self.hass.async_add_executor_job(_try_connect, import_data)
+ except ConnectionError, RequestsConnectionError:
+ return self.async_abort(reason="cannot_connect")
+ except InvalidAuth:
+ return self.async_abort(reason="invalid_auth")
+
+ return self.async_create_entry(
+ title=import_data[CONF_HOST],
+ data=import_data,
+ )
+
+
+def _try_connect(user_input: dict[str, Any]) -> None:
+ """Try to connect and authenticate with the router."""
+ router = OpenWrtRpc(
+ user_input[CONF_HOST],
+ user_input[CONF_USERNAME],
+ user_input[CONF_PASSWORD],
+ user_input.get(CONF_SSL, DEFAULT_SSL),
+ user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
+ )
+ if not router.is_logged_in():
+ raise InvalidAuth
diff --git a/homeassistant/components/luci/const.py b/homeassistant/components/luci/const.py
new file mode 100644
index 000000000000..e8d34b6b3354
--- /dev/null
+++ b/homeassistant/components/luci/const.py
@@ -0,0 +1,10 @@
+"""Constants for the OpenWrt (luci) integration."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "luci"
+
+PLATFORMS = [Platform.DEVICE_TRACKER]
+
+DEFAULT_SSL = True
+DEFAULT_VERIFY_SSL = False
diff --git a/homeassistant/components/luci/coordinator.py b/homeassistant/components/luci/coordinator.py
new file mode 100644
index 000000000000..c57f7d0f3b59
--- /dev/null
+++ b/homeassistant/components/luci/coordinator.py
@@ -0,0 +1,55 @@
+"""DataUpdateCoordinator for the OpenWrt (luci) integration."""
+
+from datetime import timedelta
+import logging
+from typing import Any, override
+
+from openwrt_luci_rpc import OpenWrtRpc
+from openwrt_luci_rpc.exceptions import LuciRpcUnknownError
+from requests.exceptions import ConnectionError as RequestsConnectionError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+type LuciConfigEntry = ConfigEntry[LuciCoordinator]
+
+
+class LuciCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+ """Coordinator for fetching connected devices from an OpenWrt router."""
+
+ config_entry: LuciConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: LuciConfigEntry,
+ router: OpenWrtRpc,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name="luci",
+ update_interval=SCAN_INTERVAL,
+ )
+ self.router = router
+
+ @override
+ async def _async_update_data(self) -> dict[str, Any]:
+ """Fetch data from the router."""
+ try:
+ result = await self.hass.async_add_executor_job(
+ lambda: self.router.get_all_connected_devices(only_reachable=True)
+ )
+ except (ConnectionError, RequestsConnectionError, LuciRpcUnknownError) as err:
+ raise UpdateFailed(f"Error communicating with router: {err}") from err
+
+ _LOGGER.debug("Luci get_all_connected_devices returned: %s", result)
+
+ return {device.mac: device for device in result}
diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py
index 172e6cd5422d..e4f87070c787 100644
--- a/homeassistant/components/luci/device_tracker.py
+++ b/homeassistant/components/luci/device_tracker.py
@@ -1,16 +1,16 @@
"""Support for OpenWRT (luci) routers."""
import logging
-from typing import override
+from typing import Any, override
-from openwrt_luci_rpc import OpenWrtRpc
import voluptuous as vol
from homeassistant.components.device_tracker import (
- DOMAIN as DEVICE_TRACKER_DOMAIN,
PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
- DeviceScanner,
+ AsyncSeeCallback,
+ ScannerEntity,
)
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -18,14 +18,20 @@ from homeassistant.const import (
CONF_USERNAME,
CONF_VERIFY_SSL,
)
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
+from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers import issue_registry as ir
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+from .coordinator import LuciConfigEntry, LuciCoordinator
_LOGGER = logging.getLogger(__name__)
-DEFAULT_SSL = False
-DEFAULT_VERIFY_SSL = True
+PARALLEL_UPDATES = 0
PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
{
@@ -38,73 +44,111 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
)
-def get_scanner(hass: HomeAssistant, config: ConfigType) -> LuciDeviceScanner | None:
- """Validate the configuration and return a Luci scanner."""
- scanner = LuciDeviceScanner(config[DEVICE_TRACKER_DOMAIN])
+async def async_setup_scanner(
+ hass: HomeAssistant,
+ config: ConfigType,
+ async_see: AsyncSeeCallback,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> bool:
+ """Import legacy YAML configuration."""
- return scanner if scanner.success_init else None
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={
+ CONF_HOST: config[CONF_HOST],
+ CONF_USERNAME: config[CONF_USERNAME],
+ CONF_PASSWORD: config[CONF_PASSWORD],
+ CONF_SSL: config.get(CONF_SSL, DEFAULT_SSL),
+ CONF_VERIFY_SSL: config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
+ },
+ )
-
-class LuciDeviceScanner(DeviceScanner):
- """Scanner for devices connected to an OpenWrt router."""
-
- def __init__(self, config):
- """Initialize the scanner."""
-
- self.router = OpenWrtRpc(
- config[CONF_HOST],
- config[CONF_USERNAME],
- config[CONF_PASSWORD],
- config[CONF_SSL],
- config[CONF_VERIFY_SSL],
- )
-
- self.last_results = {}
- self.success_init = self.router.is_logged_in()
-
- @override
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
-
- return [device.mac for device in self.last_results]
-
- @override
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- return next(
- (result.hostname for result in self.last_results if result.mac == device),
- None,
- )
-
- @override
- def get_extra_attributes(self, device):
- """Get extra attributes of a device.
-
- Some known extra attributes that may be returned in the device tuple
- include MAC address (mac), network device (dev), IP address
- (ip), reachable status (reachable), associated router
- (host), hostname if known (hostname) among others.
- """
- if not (
- device := next(
- (result for result in self.last_results if result.mac == device), None
+ if result["type"] is FlowResultType.ABORT:
+ reason = result["reason"]
+ if reason in ("invalid_auth", "cannot_connect"):
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"yaml_import_{reason}",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key=f"yaml_import_{reason}",
+ translation_placeholders={"host": config[CONF_HOST]},
)
- ):
- return {}
- return device._asdict()
+ return True
- def _update_info(self):
- """Check the Luci router for devices."""
- result = self.router.get_all_connected_devices(only_reachable=True)
+ ir.async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"deprecated_yaml_{DOMAIN}",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "OpenWrt (luci)",
+ },
+ )
- _LOGGER.debug("Luci get_all_connected_devices returned: %s", result)
+ return True
- self.last_results = [
- device
- for device in result
- if not hasattr(self.router.router.owrt_version, "release")
- or not self.router.router.owrt_version.release
- or self.router.router.owrt_version.release[0] < 19
- or device.reachable
- ]
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LuciConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up device tracker for OpenWrt (luci) component."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ LuciScannerEntity(coordinator, mac, device)
+ for mac, device in coordinator.data.items()
+ )
+
+
+class LuciScannerEntity(CoordinatorEntity[LuciCoordinator], ScannerEntity):
+ """Representation of a device connected to an OpenWrt router."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: LuciCoordinator,
+ mac: str,
+ device: Any,
+ ) -> None:
+ """Initialize the scanner entity."""
+ super().__init__(coordinator)
+ self._mac = mac
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{mac}"
+ self._attr_mac_address = mac
+ self._attr_hostname = device.hostname
+ self._attr_ip_address = device.ip
+ self._attr_name = device.hostname or mac
+
+ @property
+ @override
+ def unique_id(self) -> str | None:
+ """Return the unique ID of the entity."""
+ return self._attr_unique_id
+
+ @property
+ @override
+ def is_connected(self) -> bool:
+ """Return true if the device is connected to the router."""
+ return self._mac in self.coordinator.data
+
+ @callback
+ @override
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ if self._mac in self.coordinator.data:
+ device = self.coordinator.data[self._mac]
+ self._attr_hostname = device.hostname
+ self._attr_ip_address = device.ip
+ self._attr_name = device.hostname or self._mac
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json
index a8df2c63df4b..111bb6a6bd76 100644
--- a/homeassistant/components/luci/manifest.json
+++ b/homeassistant/components/luci/manifest.json
@@ -2,9 +2,11 @@
"domain": "luci",
"name": "OpenWrt (luci)",
"codeowners": ["@mzdrale"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/luci",
+ "integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["openwrt_luci_rpc"],
- "quality_scale": "legacy",
+ "quality_scale": "silver",
"requirements": ["openwrt-luci-rpc==1.1.17"]
}
diff --git a/homeassistant/components/luci/quality_scale.yaml b/homeassistant/components/luci/quality_scale.yaml
new file mode 100644
index 000000000000..37debf5684b9
--- /dev/null
+++ b/homeassistant/components/luci/quality_scale.yaml
@@ -0,0 +1,100 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: This integration does not provide any 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 any actions.
+ docs-conditions:
+ status: exempt
+ comment: This integration does not provide any conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: This integration does not provide any triggers.
+ entity-event-setup:
+ status: exempt
+ comment: Entities do not subscribe to events; they are updated via the coordinator.
+ 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 any actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: This integration does not have an options flow.
+ 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:
+ status: todo
+ comment: Tracked clients are represented as scanner entities without a device registry entry.
+ diagnostics: todo
+ discovery:
+ status: todo
+ comment: The router could be discovered via DHCP.
+ discovery-update-info: 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: todo
+ comment: Clients connecting after setup are not added until Home Assistant restarts.
+ entity-category:
+ status: exempt
+ comment: This integration only creates device tracker entities.
+ entity-device-class:
+ status: exempt
+ comment: This integration only creates device tracker entities.
+ entity-disabled-by-default:
+ status: exempt
+ comment: This integration only creates device tracker entities.
+ entity-translations:
+ status: exempt
+ comment: Device tracker entities are named after the tracked client's hostname.
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: Device tracker entities use the icon of their source type.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: This integration has no repairs beyond the deprecated YAML import issue.
+ stale-devices:
+ status: todo
+ comment: Clients that stop connecting are not removed automatically.
+
+ # Platinum
+ async-dependency:
+ status: todo
+ comment: The openwrt-luci-rpc library is synchronous and is run in the executor.
+ inject-websession:
+ status: exempt
+ comment: The openwrt-luci-rpc library uses requests and does not accept an injected websession.
+ strict-typing: todo
diff --git a/homeassistant/components/luci/strings.json b/homeassistant/components/luci/strings.json
new file mode 100644
index 000000000000..fe28ec962dcc
--- /dev/null
+++ b/homeassistant/components/luci/strings.json
@@ -0,0 +1,52 @@
+{
+ "config": {
+ "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%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
+ },
+ "step": {
+ "reauth_confirm": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]",
+ "username": "[%key:common::config_flow::data::username%]"
+ },
+ "data_description": {
+ "password": "The password to log in to the OpenWrt router.",
+ "username": "The username to log in to the OpenWrt router."
+ }
+ },
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "ssl": "[%key:common::config_flow::data::ssl%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of your OpenWrt router.",
+ "password": "The password to log in to the OpenWrt router.",
+ "ssl": "Whether to connect to the router using SSL.",
+ "username": "The username to log in to the OpenWrt router.",
+ "verify_ssl": "Whether to verify the router's SSL certificate."
+ }
+ }
+ }
+ },
+ "issues": {
+ "yaml_import_cannot_connect": {
+ "description": "The YAML configuration for OpenWrt (luci) at `{host}` could not be imported because the connection failed.\n\nPlease check that `{host}` is reachable, update your `configuration.yaml` if needed, and restart Home Assistant.",
+ "title": "YAML configuration import failed: cannot connect"
+ },
+ "yaml_import_invalid_auth": {
+ "description": "The YAML configuration for OpenWrt (luci) at `{host}` could not be imported because the credentials are invalid.\n\nPlease update your `configuration.yaml` with valid credentials and restart Home Assistant.",
+ "title": "YAML configuration import failed: invalid authentication"
+ }
+ }
+}
diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py
index 04752d5493d0..6317b7e0e934 100644
--- a/homeassistant/components/luftdaten/sensor.py
+++ b/homeassistant/components/luftdaten/sensor.py
@@ -9,9 +9,8 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_SHOW_ON_MAP,
+ EntityStateAttribute,
UnitOfDensity,
UnitOfPressure,
UnitOfRatio,
@@ -120,12 +119,12 @@ class SensorCommunitySensor(CoordinatorEntity, SensorEntity):
)
if show_on_map:
- self._attr_extra_state_attributes[ATTR_LONGITUDE] = coordinator.data[
- "longitude"
- ]
- self._attr_extra_state_attributes[ATTR_LATITUDE] = coordinator.data[
- "latitude"
- ]
+ self._attr_extra_state_attributes[EntityStateAttribute.LONGITUDE] = (
+ coordinator.data["longitude"]
+ )
+ self._attr_extra_state_attributes[EntityStateAttribute.LATITUDE] = (
+ coordinator.data["latitude"]
+ )
@property
@override
diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py
index 52b48c8ca5fb..f5a591e5f296 100644
--- a/homeassistant/components/lunatone/coordinator.py
+++ b/homeassistant/components/lunatone/coordinator.py
@@ -139,6 +139,7 @@ class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Senso
async def _async_update_data(self) -> dict[int, Sensor]:
"""Update sensor data."""
try:
+ await self.sensors_api.async_refresh()
await self.sensors_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json
index b08676082cba..ae234713e72a 100644
--- a/homeassistant/components/lutron/manifest.json
+++ b/homeassistant/components/lutron/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pylutron"],
- "requirements": ["pylutron==0.4.1"],
+ "requirements": ["pylutron==0.4.2"],
"single_config_entry": true
}
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
index d53187425161..f6bba1ab2a34 100644
--- a/homeassistant/components/lutron_caseta/manifest.json
+++ b/homeassistant/components/lutron_caseta/manifest.json
@@ -10,7 +10,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
- "requirements": ["pylutron-caseta==0.28.0"],
+ "requirements": ["pylutron-caseta==0.29.0"],
"zeroconf": [
{
"properties": {
diff --git a/homeassistant/components/lyngdorf/__init__.py b/homeassistant/components/lyngdorf/__init__.py
new file mode 100644
index 000000000000..56d8b3db398e
--- /dev/null
+++ b/homeassistant/components/lyngdorf/__init__.py
@@ -0,0 +1,130 @@
+"""The Lyngdorf integration."""
+
+import logging
+
+from lyngdorf.device import async_create_receiver, lookup_receiver_model
+
+from homeassistant.const import CONF_HOST, CONF_MODEL, EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.device_registry import (
+ CONNECTION_NETWORK_MAC,
+ DeviceInfo,
+ format_mac,
+)
+
+from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS
+from .models import LyngdorfConfigEntry, LyngdorfRuntimeData
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _serial_as_mac(serial: str) -> str | None:
+ """Return a normalized MAC if the serial is one, otherwise None.
+
+ Lyngdorf reports the device MAC in the UPnP serialNumber field, but this is
+ not formally guaranteed — fall back gracefully if the value is not a MAC.
+ """
+ cleaned = serial.replace(":", "").replace("-", "").replace(".", "")
+ if len(cleaned) != 12 or not all(c in "0123456789abcdefABCDEF" for c in cleaned):
+ return None
+ return format_mac(cleaned)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: LyngdorfConfigEntry
+) -> bool:
+ """Set up Lyngdorf from a config entry."""
+ lyngdorf_model = lookup_receiver_model(config_entry.data[CONF_MODEL])
+ assert lyngdorf_model is not None
+
+ try:
+ receiver = await async_create_receiver(
+ config_entry.data[CONF_HOST], lyngdorf_model
+ )
+ await receiver.async_connect()
+ except TimeoutError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="setup_timeout",
+ translation_placeholders={"host": config_entry.data[CONF_HOST]},
+ ) from err
+ except (ConnectionError, OSError) as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="setup_connection_error",
+ translation_placeholders={"host": config_entry.data[CONF_HOST]},
+ ) from err
+
+ assert config_entry.unique_id
+ serial = config_entry.data[CONF_SERIAL_NUMBER]
+ mac = _serial_as_mac(serial)
+ connections = {(CONNECTION_NETWORK_MAC, mac)} if mac else set()
+
+ device_info = DeviceInfo(
+ identifiers={(DOMAIN, config_entry.unique_id)},
+ connections=connections,
+ manufacturer=lyngdorf_model.manufacturer,
+ serial_number=serial,
+ model=lyngdorf_model.model_name,
+ )
+
+ zone_b_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{config_entry.unique_id}_zone_b")},
+ manufacturer=lyngdorf_model.manufacturer,
+ serial_number=serial,
+ model=lyngdorf_model.model_name,
+ translation_key="zone_b",
+ translation_placeholders={"device_name": config_entry.title},
+ via_device=(DOMAIN, config_entry.unique_id),
+ )
+
+ config_entry.runtime_data = LyngdorfRuntimeData(
+ receiver=receiver,
+ device_info=device_info,
+ zone_b_device_info=zone_b_device_info,
+ )
+
+ host = config_entry.data[CONF_HOST]
+ last_connected = receiver.connected
+
+ @callback
+ def _log_availability_change() -> None:
+ nonlocal last_connected
+ connected = receiver.connected
+ if connected == last_connected:
+ return
+ last_connected = connected
+ if connected:
+ _LOGGER.info("Lyngdorf %s is back online", host)
+ else:
+ _LOGGER.info("Lyngdorf %s is unavailable", host)
+
+ receiver.register_notification_callback(_log_availability_change)
+ config_entry.async_on_unload(
+ lambda: receiver.un_register_notification_callback(_log_availability_change)
+ )
+
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+
+ async def _async_disconnect(event: Event) -> None:
+ """Disconnect from receiver."""
+ await receiver.async_disconnect()
+
+ config_entry.async_on_unload(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect)
+ )
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: LyngdorfConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ unload_ok = await hass.config_entries.async_unload_platforms(
+ config_entry, PLATFORMS
+ )
+ if unload_ok:
+ await config_entry.runtime_data.receiver.async_disconnect()
+ return unload_ok
diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py
new file mode 100644
index 000000000000..2ebd56213690
--- /dev/null
+++ b/homeassistant/components/lyngdorf/config_flow.py
@@ -0,0 +1,175 @@
+"""Config flow for Lyngdorf integration."""
+
+from typing import Any, override
+from urllib.parse import urlparse
+
+from lyngdorf.device import (
+ async_find_receiver_model,
+ async_get_device_serial,
+ lookup_receiver_model,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_MODEL
+from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
+
+from .const import CONF_SERIAL_NUMBER, DEFAULT_DEVICE_NAME, DOMAIN
+
+
+class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Handle a Lyngdorf config flow."""
+
+ def __init__(self) -> None:
+ """Initialize flow."""
+ self._location: str | None = None
+ self._device_model: str | None = None
+ self._device_serial_number: str | None = None
+ self._name: str | None = None
+ self._host: str | None = None
+
+ @property
+ def _display_name(self) -> str:
+ """Return the name shown to the user during discovery confirmation."""
+ if self._device_model and self._device_model != self._name:
+ return f"{self._device_model} ({self._name})"
+ return self._name or DEFAULT_DEVICE_NAME
+
+ @override
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ self._host = user_input[CONF_HOST]
+
+ try:
+ model = await async_find_receiver_model(self._host)
+ except TimeoutError:
+ errors["base"] = "timeout_connect"
+ except OSError:
+ errors["base"] = "cannot_connect"
+ except Exception: # noqa: BLE001
+ errors["base"] = "unknown"
+
+ if not errors and not model:
+ errors["base"] = "unsupported_model"
+
+ if not errors and model:
+ self._device_model = model.model_name
+ self._name = model.model_name
+
+ serial = await async_get_device_serial(self._host)
+ if not serial:
+ errors["base"] = "cannot_determine_id"
+ else:
+ self._device_serial_number = serial.lower()
+ await self.async_set_unique_id(self._device_serial_number)
+ self._abort_if_unique_id_configured()
+ return await self._create_entry()
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ }
+ ),
+ errors=errors,
+ )
+
+ @override
+ async def async_step_ssdp(
+ self, discovery_info: SsdpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by SSDP discovery."""
+ await self._async_set_info_from_discovery(discovery_info)
+
+ assert self._host
+ try:
+ model = await async_find_receiver_model(self._host)
+ except TimeoutError, OSError:
+ return self.async_abort(reason="cannot_connect")
+ if not model:
+ return self.async_abort(reason="unsupported_model")
+
+ self.context["title_placeholders"] = {"name": self._display_name}
+
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Allow the user to confirm adding the device."""
+ if user_input is not None:
+ return await self._create_entry()
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="confirm",
+ description_placeholders={"name": self._display_name},
+ )
+
+ async def _create_entry(self) -> ConfigFlowResult:
+ """Create a config entry."""
+ assert self._host
+ assert self._device_model
+ if self._location:
+ title = (
+ self._name or urlparse(self._location).hostname or DEFAULT_DEVICE_NAME
+ )
+ else:
+ title = self._name or DEFAULT_DEVICE_NAME
+
+ data: dict[str, Any] = {
+ CONF_MODEL: self._device_model,
+ CONF_HOST: self._host,
+ }
+ if self._device_serial_number:
+ data[CONF_SERIAL_NUMBER] = self._device_serial_number
+
+ return self.async_create_entry(title=title, data=data)
+
+ async def _async_set_info_from_discovery(
+ self, discovery_info: SsdpServiceInfo
+ ) -> None:
+ """Set information required for a config entry from SSDP discovery."""
+ if not self._location:
+ self._location = discovery_info.ssdp_location
+ if not isinstance(self._location, str):
+ raise AbortFlow("cannot_connect")
+
+ if hostname := (
+ discovery_info.ssdp_headers.get("_host")
+ or urlparse(self._location).hostname
+ ):
+ self._host = str(hostname)
+ else:
+ raise AbortFlow("cannot_connect")
+
+ device_model_name = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or ""
+ if not (model := lookup_receiver_model(device_model_name)):
+ raise AbortFlow("unsupported_model")
+ self._device_model = model.model_name
+ self._device_serial_number = (
+ discovery_info.upnp.get(ATTR_UPNP_SERIAL) or ""
+ ).lower() or None
+ self._name = (
+ discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME)
+ or urlparse(self._location).hostname
+ or DEFAULT_DEVICE_NAME
+ )
+
+ if not self._device_serial_number:
+ raise AbortFlow("cannot_determine_id")
+ await self.async_set_unique_id(self._device_serial_number)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
diff --git a/homeassistant/components/lyngdorf/const.py b/homeassistant/components/lyngdorf/const.py
new file mode 100644
index 000000000000..d34961be3d5f
--- /dev/null
+++ b/homeassistant/components/lyngdorf/const.py
@@ -0,0 +1,11 @@
+"""Constants for the Lyngdorf integration."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "lyngdorf"
+DEFAULT_DEVICE_NAME = "Lyngdorf"
+
+PLATFORMS: list[Platform] = [
+ Platform.MEDIA_PLAYER,
+]
+CONF_SERIAL_NUMBER = "serial_number"
diff --git a/homeassistant/components/lyngdorf/entity.py b/homeassistant/components/lyngdorf/entity.py
new file mode 100644
index 000000000000..4c95d27e86d1
--- /dev/null
+++ b/homeassistant/components/lyngdorf/entity.py
@@ -0,0 +1,46 @@
+"""Base entity for Lyngdorf integration."""
+
+from typing import override
+
+from lyngdorf.device import Receiver
+
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import Entity
+
+
+class LyngdorfEntity(Entity):
+ """Base Lyngdorf entity."""
+
+ _attr_has_entity_name = True
+ _attr_available = True
+ _attr_should_poll = False
+
+ def __init__(self, receiver: Receiver, device_info: DeviceInfo) -> None:
+ """Initialize the entity."""
+ self._receiver = receiver
+ self._attr_device_info = device_info
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """Register notification callback when added to hass."""
+ await super().async_added_to_hass()
+ self._receiver.register_notification_callback(self._handle_receiver_update)
+ self._update_availability()
+
+ @override
+ async def async_will_remove_from_hass(self) -> None:
+ """Unregister notification callback when removed from hass."""
+ await super().async_will_remove_from_hass()
+ self._receiver.un_register_notification_callback(self._handle_receiver_update)
+
+ @callback
+ def _handle_receiver_update(self) -> None:
+ """Handle receiver updates."""
+ self._update_availability()
+ self.async_write_ha_state()
+
+ @callback
+ def _update_availability(self) -> None:
+ """Update availability from receiver connection status."""
+ self._attr_available = self._receiver.connected
diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json
new file mode 100644
index 000000000000..c4144acf1011
--- /dev/null
+++ b/homeassistant/components/lyngdorf/manifest.json
@@ -0,0 +1,19 @@
+{
+ "domain": "lyngdorf",
+ "name": "Lyngdorf",
+ "codeowners": ["@fishloa"],
+ "config_flow": true,
+ "dependencies": ["ssdp"],
+ "documentation": "https://www.home-assistant.io/integrations/lyngdorf",
+ "integration_type": "device",
+ "iot_class": "local_push",
+ "loggers": ["lyngdorf", "async_upnp_client"],
+ "quality_scale": "silver",
+ "requirements": ["lyngdorf==1.3.3"],
+ "ssdp": [
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
+ "manufacturer": "Lyngdorf"
+ }
+ ]
+}
diff --git a/homeassistant/components/lyngdorf/media_player.py b/homeassistant/components/lyngdorf/media_player.py
new file mode 100644
index 000000000000..dd764c9d0fec
--- /dev/null
+++ b/homeassistant/components/lyngdorf/media_player.py
@@ -0,0 +1,288 @@
+"""Media player platform for Lyngdorf integration."""
+
+from typing import override
+
+from lyngdorf.device import Receiver
+
+from homeassistant.components.media_player import (
+ MediaPlayerDeviceClass,
+ MediaPlayerEntity,
+ MediaPlayerEntityFeature,
+ MediaPlayerState,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .entity import LyngdorfEntity
+from .models import LyngdorfConfigEntry
+
+PARALLEL_UPDATES = 1
+
+MAX_VOLUME_DB = 18.0
+MIN_VOLUME_DB = -80.0
+VOLUME_RANGE = MAX_VOLUME_DB - MIN_VOLUME_DB
+
+FEATURES_ZONE_B = (
+ MediaPlayerEntityFeature.VOLUME_STEP
+ | MediaPlayerEntityFeature.VOLUME_SET
+ | MediaPlayerEntityFeature.VOLUME_MUTE
+ | MediaPlayerEntityFeature.TURN_ON
+ | MediaPlayerEntityFeature.TURN_OFF
+ | MediaPlayerEntityFeature.SELECT_SOURCE
+)
+
+FEATURES_MAIN = (
+ MediaPlayerEntityFeature.VOLUME_STEP
+ | MediaPlayerEntityFeature.VOLUME_SET
+ | MediaPlayerEntityFeature.VOLUME_MUTE
+ | MediaPlayerEntityFeature.TURN_ON
+ | MediaPlayerEntityFeature.TURN_OFF
+ | MediaPlayerEntityFeature.SELECT_SOUND_MODE
+ | MediaPlayerEntityFeature.SELECT_SOURCE
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: LyngdorfConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the receiver from a config entry."""
+ runtime_data = config_entry.runtime_data
+
+ async_add_entities(
+ [
+ LyngdorfMainDevice(
+ runtime_data.receiver, config_entry, runtime_data.device_info
+ ),
+ LyngdorfZoneBDevice(
+ runtime_data.receiver, config_entry, runtime_data.zone_b_device_info
+ ),
+ ]
+ )
+
+
+def _to_ha_volume(volume_db: float) -> float:
+ """Convert Lyngdorf dB volume to HA 0..1 scale, clamped to 0..1."""
+ volume = (volume_db - MIN_VOLUME_DB) / VOLUME_RANGE
+ return max(0.0, min(volume, 1.0))
+
+
+def _to_lyngdorf_volume(volume: float) -> float:
+ """Convert HA 0..1 volume to Lyngdorf dB scale, clamped to min and max."""
+ volume_db = volume * VOLUME_RANGE + MIN_VOLUME_DB
+ return max(MIN_VOLUME_DB, min(volume_db, MAX_VOLUME_DB))
+
+
+class LyngdorfDevice(LyngdorfEntity, MediaPlayerEntity):
+ """Base Lyngdorf media player entity."""
+
+ _attr_device_class = MediaPlayerDeviceClass.RECEIVER
+
+ def __init__(
+ self,
+ receiver: Receiver,
+ config_entry: LyngdorfConfigEntry,
+ device_info: DeviceInfo,
+ translation_key: str | None,
+ entity_id_suffix: str,
+ features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0),
+ ) -> None:
+ """Initialize the device."""
+ super().__init__(receiver, device_info)
+ assert config_entry.unique_id
+ self._attr_unique_id = f"{config_entry.unique_id}_{entity_id_suffix}"
+ self._attr_translation_key = translation_key
+ self._attr_supported_features = features
+
+
+class LyngdorfZoneBDevice(LyngdorfDevice):
+ """Lyngdorf Zone B device."""
+
+ def __init__(
+ self,
+ receiver: Receiver,
+ config_entry: LyngdorfConfigEntry,
+ device_info: DeviceInfo,
+ ) -> None:
+ """Create the device."""
+ super().__init__(
+ receiver,
+ config_entry,
+ device_info,
+ None,
+ "zone_b",
+ FEATURES_ZONE_B,
+ )
+
+ @override
+ @property
+ def state(self) -> MediaPlayerState | None:
+ """Return the state of the device."""
+ if self._receiver.zone_b_power_on:
+ return MediaPlayerState.ON
+ return MediaPlayerState.OFF
+
+ @override
+ @property
+ def is_volume_muted(self) -> bool | None:
+ """Return boolean if volume is currently muted."""
+ return self._receiver.zone_b_mute_enabled
+
+ @override
+ @property
+ def volume_level(self) -> float | None:
+ """Volume level of the media player (0..1)."""
+ if not isinstance(self._receiver.zone_b_volume, float):
+ return None
+ return _to_ha_volume(self._receiver.zone_b_volume)
+
+ @override
+ def turn_on(self) -> None:
+ """Turn on media player."""
+ self._receiver.zone_b_power_on = True
+
+ @override
+ def turn_off(self) -> None:
+ """Turn off media player."""
+ self._receiver.zone_b_power_on = False
+
+ def volume_up(self) -> None:
+ """Volume up the media player."""
+ self._receiver.zone_b_volume_up()
+
+ def volume_down(self) -> None:
+ """Volume down the media player."""
+ self._receiver.zone_b_volume_down()
+
+ @override
+ def set_volume_level(self, volume: float) -> None:
+ """Set volume level, range 0..1."""
+ self._receiver.zone_b_volume = _to_lyngdorf_volume(volume)
+
+ @override
+ def mute_volume(self, mute: bool) -> None:
+ """Send mute command."""
+ self._receiver.zone_b_mute_enabled = mute
+
+ @override
+ @property
+ def source(self) -> str | None:
+ """Return the current input source."""
+ return self._receiver.zone_b_source
+
+ @override
+ @property
+ def source_list(self) -> list[str] | None:
+ """Return the list of available sources."""
+ return self._receiver.zone_b_available_sources
+
+ @override
+ def select_source(self, source: str) -> None:
+ """Select input source."""
+ self._receiver.zone_b_source = source
+
+
+class LyngdorfMainDevice(LyngdorfDevice):
+ """Lyngdorf main zone device."""
+
+ def __init__(
+ self,
+ receiver: Receiver,
+ config_entry: LyngdorfConfigEntry,
+ device_info: DeviceInfo,
+ ) -> None:
+ """Create the device."""
+ super().__init__(
+ receiver,
+ config_entry,
+ device_info,
+ "main_zone",
+ "main_zone",
+ FEATURES_MAIN,
+ )
+
+ @override
+ @property
+ def state(self) -> MediaPlayerState | None:
+ """Return the state of the device."""
+ if self._receiver.power_on:
+ return MediaPlayerState.ON
+ return MediaPlayerState.OFF
+
+ @override
+ @property
+ def source_list(self) -> list[str] | None:
+ """Return a list of available input sources."""
+ return self._receiver.available_sources
+
+ @override
+ @property
+ def sound_mode_list(self) -> list[str] | None:
+ """Return a list of available sound modes."""
+ return self._receiver.available_sound_modes
+
+ @override
+ @property
+ def is_volume_muted(self) -> bool | None:
+ """Return boolean if volume is currently muted."""
+ return self._receiver.mute_enabled
+
+ @override
+ @property
+ def volume_level(self) -> float | None:
+ """Volume level of the media player (0..1)."""
+ if not isinstance(self._receiver.volume, float):
+ return None
+ return _to_ha_volume(self._receiver.volume)
+
+ @override
+ @property
+ def source(self) -> str | None:
+ """Return the current input source."""
+ return self._receiver.source
+
+ @override
+ @property
+ def sound_mode(self) -> str | None:
+ """Return the current sound mode."""
+ return self._receiver.sound_mode
+
+ @override
+ def turn_on(self) -> None:
+ """Turn on media player."""
+ self._receiver.power_on = True
+
+ @override
+ def turn_off(self) -> None:
+ """Turn off media player."""
+ self._receiver.power_on = False
+
+ def volume_up(self) -> None:
+ """Volume up the media player."""
+ self._receiver.volume_up()
+
+ def volume_down(self) -> None:
+ """Volume down the media player."""
+ self._receiver.volume_down()
+
+ @override
+ def set_volume_level(self, volume: float) -> None:
+ """Set volume level, range 0..1."""
+ self._receiver.volume = _to_lyngdorf_volume(volume)
+
+ @override
+ def mute_volume(self, mute: bool) -> None:
+ """Send mute command."""
+ self._receiver.mute_enabled = mute
+
+ @override
+ def select_sound_mode(self, sound_mode: str) -> None:
+ """Select sound mode."""
+ self._receiver.sound_mode = sound_mode
+
+ @override
+ def select_source(self, source: str) -> None:
+ """Select input source."""
+ self._receiver.source = source
diff --git a/homeassistant/components/lyngdorf/models.py b/homeassistant/components/lyngdorf/models.py
new file mode 100644
index 000000000000..26e2f84b027a
--- /dev/null
+++ b/homeassistant/components/lyngdorf/models.py
@@ -0,0 +1,20 @@
+"""Models for Lyngdorf integration."""
+
+from dataclasses import dataclass
+
+from lyngdorf.device import Receiver
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.device_registry import DeviceInfo
+
+
+@dataclass
+class LyngdorfRuntimeData:
+ """Runtime data for Lyngdorf integration."""
+
+ receiver: Receiver
+ device_info: DeviceInfo
+ zone_b_device_info: DeviceInfo
+
+
+type LyngdorfConfigEntry = ConfigEntry[LyngdorfRuntimeData]
diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml
new file mode 100644
index 000000000000..a9a72c80f4d9
--- /dev/null
+++ b/homeassistant/components/lyngdorf/quality_scale.yaml
@@ -0,0 +1,92 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register custom actions.
+ appropriate-polling:
+ status: exempt
+ comment: Integration is push-based; entities update via receiver callbacks.
+ brands: done
+ common-modules: done
+ config-flow: done
+ config-flow-test-coverage: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: Integration does not register custom actions.
+ docs-conditions:
+ status: exempt
+ comment: Integration does not register custom conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: Integration does not register custom triggers.
+ 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: 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: Integration does not use authentication.
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery: done
+ discovery-update-info: 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: Single device per config entry.
+ entity-category:
+ status: exempt
+ comment: Media player entities do not need entity categories.
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: All entities are useful by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: Media player uses default platform icons.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: No repair issues needed.
+ stale-devices:
+ status: exempt
+ comment: Single device per config entry.
+
+ # Platinum
+ async-dependency: todo
+ inject-websession:
+ status: exempt
+ comment: Integration uses local TCP, not HTTP.
+ strict-typing: todo
diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json
new file mode 100644
index 000000000000..c47d256d32b8
--- /dev/null
+++ b/homeassistant/components/lyngdorf/strings.json
@@ -0,0 +1,53 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "cannot_determine_id": "[%key:component::lyngdorf::config::error::cannot_determine_id%]",
+ "unsupported_model": "This Lyngdorf model is not supported"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "cannot_determine_id": "Could not determine device identity. Ensure the device is powered on and reachable.",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "unsupported_model": "This Lyngdorf model is not supported"
+ },
+ "flow_title": "{name}",
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up **{name}**?"
+ },
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "Hostname or IP address of the Lyngdorf device"
+ },
+ "title": "Lyngdorf device connection"
+ }
+ }
+ },
+ "device": {
+ "zone_b": {
+ "name": "{device_name} Zone B"
+ }
+ },
+ "entity": {
+ "media_player": {
+ "main_zone": {
+ "name": "Main zone"
+ }
+ }
+ },
+ "exceptions": {
+ "setup_connection_error": {
+ "message": "Failed to connect to {host}"
+ },
+ "setup_timeout": {
+ "message": "Timeout connecting to {host}"
+ }
+ }
+}
diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py
index 1ceb8721fae3..b46b8be84a34 100644
--- a/homeassistant/components/mastodon/services.py
+++ b/homeassistant/components/mastodon/services.py
@@ -14,6 +14,8 @@ from mastodon.Mastodon import (
MastodonNotFoundError,
MastodonUnauthorizedError,
MediaAttachment,
+ ScheduledStatus,
+ Status,
)
import voluptuous as vol
@@ -177,7 +179,11 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=SERVICE_UNMUTE_ACCOUNT_SCHEMA,
)
hass.services.async_register(
- DOMAIN, SERVICE_POST, _async_post, schema=SERVICE_POST_SCHEMA
+ DOMAIN,
+ SERVICE_POST,
+ _async_post,
+ schema=SERVICE_POST_SCHEMA,
+ supports_response=SupportsResponse.OPTIONAL,
)
hass.services.async_register(
DOMAIN,
@@ -326,7 +332,7 @@ async def _async_post(call: ServiceCall) -> ServiceResponse:
translation_key="idempotency_key_too_short",
)
- await call.hass.async_add_executor_job(
+ response = await call.hass.async_add_executor_job(
partial(
_post,
hass=call.hass,
@@ -344,11 +350,14 @@ async def _async_post(call: ServiceCall) -> ServiceResponse:
quoted_status_id=quoted_status,
)
)
-
+ if call.return_response:
+ return response
return None
-def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None:
+def _post(
+ hass: HomeAssistant, client: Mastodon, **kwargs: Any
+) -> Status | ScheduledStatus:
"""Post to Mastodon."""
media_data: MediaAttachment | None = None
@@ -385,12 +394,15 @@ def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None:
if media_data:
media_ids = media_data.id
try:
- client.status_post(media_ids=media_ids, **kwargs)
+ response: Status | ScheduledStatus = client.status_post(
+ media_ids=media_ids, **kwargs
+ )
except MastodonAPIError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_send_message",
) from err
+ return response
async def _async_update_profile(call: ServiceCall) -> ServiceResponse | None:
diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json
index 8755819e9505..591d58e9c5c3 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==12.2.0", "aiofiles==24.1.0"]
+ "requirements": ["matrix-nio==0.25.2", "Pillow==12.3.0", "aiofiles==24.1.0"]
}
diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py
index de54159ad3b4..304b65315bfd 100644
--- a/homeassistant/components/matter/__init__.py
+++ b/homeassistant/components/matter/__init__.py
@@ -394,9 +394,7 @@ def _remove_via_devices(
devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)
for device in devices:
if device.via_device_id == device_entry.id:
- device_registry.async_update_device(
- device.id, remove_config_entry_id=config_entry.entry_id
- )
+ device_registry.async_remove_device(device.id)
async def async_remove_config_entry_device(
diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py
index ad4d9aa4d6ce..66286b40be78 100644
--- a/homeassistant/components/matter/event.py
+++ b/homeassistant/components/matter/event.py
@@ -148,7 +148,10 @@ DISCOVERY_SCHEMAS = [
clusters.Switch.Attributes.CurrentPosition,
clusters.Switch.Attributes.FeatureMap,
),
- device_type=(device_types.GenericSwitch,),
+ device_type=(
+ device_types.Doorbell,
+ device_types.GenericSwitch,
+ ),
optional_attributes=(
clusters.Switch.Attributes.NumberOfPositions,
clusters.FixedLabel.Attributes.LabelList,
diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py
index 776d895186a8..26c12da16775 100644
--- a/homeassistant/components/matter/update.py
+++ b/homeassistant/components/matter/update.py
@@ -9,11 +9,11 @@ from matter_server.common.errors import UpdateCheckError, UpdateError
from matter_server.common.models import MatterSoftwareVersion, UpdateSource
from homeassistant.components.update import (
- ATTR_LATEST_VERSION,
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
+ UpdateEntityStateAttribute,
)
from homeassistant.const import STATE_ON, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
@@ -204,7 +204,9 @@ class MatterUpdate(MatterEntity, UpdateEntity):
await super().async_added_to_hass()
if state := await self.async_get_last_state():
- self._attr_latest_version = state.attributes.get(ATTR_LATEST_VERSION)
+ self._attr_latest_version = state.attributes.get(
+ UpdateEntityStateAttribute.LATEST_VERSION
+ )
if (extra_data := await self.async_get_last_extra_data()) and (
matter_extra_data := MatterUpdateExtraStoredData.from_dict(
diff --git a/homeassistant/components/mcp/auth.py b/homeassistant/components/mcp/auth.py
new file mode 100644
index 000000000000..6b0ee76e1990
--- /dev/null
+++ b/homeassistant/components/mcp/auth.py
@@ -0,0 +1,36 @@
+"""Authentication helper classes for the Model Context Protocol integration."""
+
+from dataclasses import dataclass
+import re
+
+import httpx
+from yarl import URL
+
+# Headers and regex for WWW-Authenticate parsing for rfc9728
+WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"
+RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"'
+SCOPES_REGEXP = r'scope="([^"]+)"'
+
+
+@dataclass
+class AuthenticateHeader:
+ """Class to hold info from the WWW-Authenticate header for supporting rfc9728."""
+
+ resource_metadata_url: str
+ scopes: list[str] | None = None
+
+ @classmethod
+ def from_header(
+ cls, url: str, error_response: httpx.Response
+ ) -> AuthenticateHeader | None:
+ """Create AuthenticateHeader from WWW-Authenticate header."""
+ if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not (
+ match := re.search(RESOURCE_METADATA_REGEXP, header)
+ ):
+ return None
+ resource_metadata_url = str(URL(url).join(URL(match.group(1))))
+ scope_match = re.search(SCOPES_REGEXP, header)
+ return cls(
+ resource_metadata_url=resource_metadata_url,
+ scopes=scope_match.group(1).split(" ") if scope_match else None,
+ )
diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py
index 1e77143359dc..982cc0a4884f 100644
--- a/homeassistant/components/mcp/config_flow.py
+++ b/homeassistant/components/mcp/config_flow.py
@@ -4,7 +4,6 @@ import asyncio
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
import logging
-import re
from typing import Any, cast, override
import httpx
@@ -24,6 +23,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from . import async_get_config_entry_implementation
from .application_credentials import authorization_server_context
+from .auth import AuthenticateHeader
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
from .coordinator import TokenManager, mcp_client
@@ -35,35 +35,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
-# Headers and regex for WWW-Authenticate parsing for rfc9728
-WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"
-RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"'
OAUTH_PROTECTED_RESOURCE_ENDPOINT = "/.well-known/oauth-protected-resource"
-SCOPES_REGEXP = r'scope="([^"]+)"'
-
-
-@dataclass
-class AuthenticateHeader:
- """Class to hold info from the WWW-Authenticate header for supporting rfc9728."""
-
- resource_metadata_url: str
- scopes: list[str] | None = None
-
- @classmethod
- def from_header(
- cls, url: str, error_response: httpx.Response
- ) -> AuthenticateHeader | None:
- """Create AuthenticateHeader from WWW-Authenticate header."""
- if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not (
- match := re.search(RESOURCE_METADATA_REGEXP, header)
- ):
- return None
- resource_metadata_url = str(URL(url).join(URL(match.group(1))))
- scope_match = re.search(SCOPES_REGEXP, header)
- return cls(
- resource_metadata_url=resource_metadata_url,
- scopes=scope_match.group(1).split(" ") if scope_match else None,
- )
@dataclass
@@ -369,6 +341,8 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
+ if entry_data and "auth_header" in entry_data:
+ self.auth_header = entry_data["auth_header"]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@@ -379,6 +353,13 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm")
config_entry = self._get_reauth_entry()
self.data = {**config_entry.data}
+ if "auth_implementation" not in self.data:
+ # For entries configured without authentication (no-auth), any authentication
+ # failure (from a tool call or coordinator update) requires upgrading to OAuth.
+ # We bypass validate_input connection handshake (which might succeed if the server
+ # doesn't restrict the connection handshake itself) and proceed directly to OAuth discovery.
+ return await self.async_step_auth_discovery()
+
self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment]
self.hass, config_entry
)
diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py
index e9bffdc0c8f5..4257449c94da 100644
--- a/homeassistant/components/mcp/coordinator.py
+++ b/homeassistant/components/mcp/coordinator.py
@@ -18,12 +18,17 @@ 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 ConfigEntryAuthFailed, HomeAssistantError
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ HomeAssistantError,
+ OAuth2TokenRequestReauthError,
+)
from homeassistant.helpers import llm
from homeassistant.helpers.httpx_client import create_async_httpx_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.json import JsonObjectType
+from .auth import AuthenticateHeader
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -98,6 +103,7 @@ class ModelContextProtocolTool(llm.Tool):
description: str | None,
parameters: vol.Schema,
server_url: str,
+ config_entry: ConfigEntry,
token_manager: TokenManager | None = None,
) -> None:
"""Initialize the tool."""
@@ -105,6 +111,7 @@ class ModelContextProtocolTool(llm.Tool):
self.description = description
self.parameters = parameters
self.server_url = server_url
+ self.config_entry = config_entry
self.token_manager = token_manager
@override
@@ -126,9 +133,32 @@ class ModelContextProtocolTool(llm.Tool):
except TimeoutError as error:
_LOGGER.debug("Timeout when calling tool: %s", error)
raise HomeAssistantError(f"Timeout when calling tool: {error}") from error
+ except OAuth2TokenRequestReauthError as error:
+ _LOGGER.debug("OAuth token request failed when calling tool: %s", error)
+ self.config_entry.async_start_reauth(hass)
+ raise ConfigEntryAuthFailed(
+ "OAuth token request failed when calling tool"
+ ) from error
except httpx.HTTPStatusError as error:
_LOGGER.debug("Error when calling tool: %s", error)
+ if error.response.status_code == 401:
+ auth_header = AuthenticateHeader.from_header(
+ self.server_url, error.response
+ )
+ self.config_entry.async_start_reauth(
+ hass, data={"auth_header": auth_header}
+ )
+ raise ConfigEntryAuthFailed(
+ "The MCP server requires authentication"
+ ) from error
raise HomeAssistantError(f"Error when calling tool: {error}") from error
+ except httpx.HTTPError as error:
+ _LOGGER.debug(
+ "Error communicating with MCP server when calling tool: %s", error
+ )
+ raise HomeAssistantError(
+ f"Error communicating with MCP server when calling tool: {error}"
+ ) from error
return result.model_dump(exclude_unset=True, exclude_none=True)
@@ -169,9 +199,18 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
except TimeoutError as error:
_LOGGER.debug("Timeout when listing tools: %s", error)
raise UpdateFailed(f"Timeout when listing tools: {error}") from error
+ except OAuth2TokenRequestReauthError as error:
+ _LOGGER.debug("OAuth token request failed: %s", error)
+ raise ConfigEntryAuthFailed("OAuth token request failed") 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:
+ if error.response.status_code == 401:
+ auth_header = AuthenticateHeader.from_header(
+ self.config_entry.data[CONF_URL], error.response
+ )
+ self.config_entry.async_start_reauth(
+ self.hass, data={"auth_header": auth_header}
+ )
raise ConfigEntryAuthFailed(
"The MCP server requires authentication"
) from error
@@ -195,6 +234,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
tool.description,
parameters,
self.config_entry.data[CONF_URL],
+ self.config_entry,
self.token_manager,
)
)
diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py
index 3af6fb4806a3..6f2857f0d9ca 100644
--- a/homeassistant/components/mcp_server/http.py
+++ b/homeassistant/components/mcp_server/http.py
@@ -3,12 +3,16 @@
This registers HTTP endpoints that support the Streamable HTTP protocol as
well as the older SSE as a transport layer.
-The Streamable HTTP protocol uses a single HTTP endpoint:
+The Streamable HTTP protocol uses these HTTP endpoints:
-- /api/mcp_server: The Streamable HTTP endpoint currently implements the
+- /api/mcp: The Streamable HTTP endpoint currently implements the
stateless protocol for simplicity. This receives client requests and
sends them to the MCP server, then waits for a response to send back to
- the client.
+ the client. This serves the configured LLM APIs and does not require
+ admin access.
+- /api/mcp/: The same Streamable HTTP endpoint, but exposing a
+ specific LLM API selected by its ID. These endpoints require admin access,
+ except for the Assist API.
The older SSE protocol has two HTTP endpoints:
@@ -43,6 +47,7 @@ from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, callback
+from homeassistant.exceptions import Unauthorized
from homeassistant.helpers import llm
from .const import DOMAIN
@@ -67,6 +72,7 @@ def async_register(hass: HomeAssistant) -> None:
hass.http.register_view(ModelContextProtocolSSEView())
hass.http.register_view(ModelContextProtocolMessagesView())
hass.http.register_view(ModelContextProtocolStreamableView())
+ hass.http.register_view(ModelContextProtocolStreamableApiView())
def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
@@ -112,6 +118,11 @@ class Streams:
@asynccontextmanager
async def create_streams() -> AsyncGenerator[Streams]:
"""Create a new pair of streams for MCP server communication."""
+ read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
+ read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
+ write_stream: MemoryObjectSendStream[SessionMessage]
+ write_stream_reader: MemoryObjectReceiveStream[SessionMessage]
+
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
streams = Streams(
@@ -127,7 +138,7 @@ async def create_streams() -> AsyncGenerator[Streams]:
async def create_mcp_server(
- hass: HomeAssistant, context: Context, entry: MCPServerConfigEntry
+ hass: HomeAssistant, context: Context, llm_api_id: str | list[str]
) -> tuple[Server, InitializationOptions]:
"""Initialize the MCP server to ensure it's ready to handle requests."""
llm_context = llm.LLMContext(
@@ -137,7 +148,6 @@ async def create_mcp_server(
assistant=conversation.DOMAIN,
device_id=None,
)
- llm_api_id = entry.data[CONF_LLM_HASS_API]
server = await create_server(hass, llm_api_id, llm_context)
options = await hass.async_add_executor_job(
server.create_initialization_options # Reads package for version info
@@ -165,7 +175,9 @@ class ModelContextProtocolSSEView(HomeAssistantView):
entry = async_get_config_entry(hass)
session_manager = entry.runtime_data
- server, options = await create_mcp_server(hass, self.context(request), entry)
+ server, options = await create_mcp_server(
+ hass, self.context(request), entry.data[CONF_LLM_HASS_API]
+ )
async with (
create_streams() as streams,
@@ -231,65 +243,92 @@ class ModelContextProtocolMessagesView(HomeAssistantView):
return web.Response(status=200)
+async def _async_handle_streamable_message(
+ request: web.Request, context: Context, llm_api_id: str | list[str]
+) -> web.StreamResponse:
+ """Process a single JSON-RPC message for the given LLM API."""
+ hass = request.app[KEY_HASS]
+
+ # The request must include a JSON-RPC message
+ if CONTENT_TYPE_JSON not in request.headers.get("accept", ""):
+ raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}")
+ if request.content_type != CONTENT_TYPE_JSON:
+ raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}")
+ try:
+ json_data = await request.json()
+ message = types.JSONRPCMessage.model_validate(json_data)
+ except ValueError as err:
+ _LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err)
+ raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err
+
+ _LOGGER.debug("Received client message: %s", message)
+
+ # For notifications and responses only, return 202 Accepted
+ if not isinstance(message.root, JSONRPCRequest):
+ _LOGGER.debug("Notification or response received, returning 202")
+ return web.Response(status=HTTPStatus.ACCEPTED)
+
+ # The MCP server runs as a background task for the duration of the
+ # request. We open a buffered stream pair to communicate with it. The
+ # request is sent to the MCP server and we wait for a single response
+ # then shut down the server.
+ server, options = await create_mcp_server(hass, context, llm_api_id)
+
+ async with create_streams() as streams:
+
+ async def run_server() -> None:
+ await server.run(
+ streams.read_stream, streams.write_stream, options, stateless=True
+ )
+
+ async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg:
+ tg.start_soon(run_server)
+
+ await streams.read_stream_writer.send(SessionMessage(message))
+ session_message = await anext(streams.write_stream_reader)
+ tg.cancel_scope.cancel()
+
+ _LOGGER.debug("Sending response: %s", session_message)
+ return web.json_response(
+ data=session_message.message.model_dump(by_alias=True, exclude_none=True),
+ )
+
+
class ModelContextProtocolStreamableView(HomeAssistantView):
- """Model Context Protocol Streamable HTTP endpoint."""
+ """Model Context Protocol Streamable HTTP endpoint.
+
+ This serves the configured LLM APIs and does not require admin access.
+ """
name = f"{DOMAIN}:streamable"
url = STREAMABLE_API
- async def get(self, request: web.Request) -> web.StreamResponse:
- """Handle unsupported methods."""
- return web.Response(
- status=HTTPStatus.METHOD_NOT_ALLOWED, text="Only POST method is supported"
- )
-
async def post(self, request: web.Request) -> web.StreamResponse:
- """Process JSON-RPC messages for the Model Context Protocol."""
+ """Process JSON-RPC messages for the configured LLM APIs."""
hass = request.app[KEY_HASS]
entry = async_get_config_entry(hass)
+ return await _async_handle_streamable_message(
+ request, self.context(request), entry.data[CONF_LLM_HASS_API]
+ )
- # The request must include a JSON-RPC message
- if CONTENT_TYPE_JSON not in request.headers.get("accept", ""):
- raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}")
- if request.content_type != CONTENT_TYPE_JSON:
- raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}")
- try:
- json_data = await request.json()
- message = types.JSONRPCMessage.model_validate(json_data)
- except ValueError as err:
- _LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err)
- raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err
- _LOGGER.debug("Received client message: %s", message)
+class ModelContextProtocolStreamableApiView(HomeAssistantView):
+ """Model Context Protocol Streamable HTTP endpoint for a specific LLM API.
- # For notifications and responses only, return 202 Accepted
- if not isinstance(message.root, JSONRPCRequest):
- _LOGGER.debug("Notification or response received, returning 202")
- return web.Response(status=HTTPStatus.ACCEPTED)
+ The LLM API is selected by its ID in the URL. These endpoints require
+ admin access, except for the Assist API.
+ """
- # The MCP server runs as a background task for the duration of the
- # request. We open a buffered stream pair to communicate with it. The
- # request is sent to the MCP server and we wait for a single response
- # then shut down the server.
- server, options = await create_mcp_server(hass, self.context(request), entry)
+ name = f"{DOMAIN}:streamable_api"
+ url = f"{STREAMABLE_API}/{{api_id}}"
- async with create_streams() as streams:
-
- async def run_server() -> None:
- await server.run(
- streams.read_stream, streams.write_stream, options, stateless=True
- )
-
- async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg:
- tg.start_soon(run_server)
-
- await streams.read_stream_writer.send(SessionMessage(message))
- session_message = await anext(streams.write_stream_reader)
- tg.cancel_scope.cancel()
-
- _LOGGER.debug("Sending response: %s", session_message)
- return web.json_response(
- data=session_message.message.model_dump(
- by_alias=True, exclude_none=True
- ),
- )
+ async def post(self, request: web.Request, api_id: str) -> web.StreamResponse:
+ """Process JSON-RPC messages for the LLM API identified by api_id."""
+ hass = request.app[KEY_HASS]
+ if api_id != llm.LLM_API_ASSIST and not request["hass_user"].is_admin:
+ raise Unauthorized
+ if api_id not in {api.id for api in llm.async_get_apis(hass)}:
+ raise HTTPNotFound(text=f"Unknown LLM API '{api_id}'")
+ return await _async_handle_streamable_message(
+ request, self.context(request), api_id
+ )
diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py
index f505c3505017..881aa71d11a1 100644
--- a/homeassistant/components/media_player/__init__.py
+++ b/homeassistant/components/media_player/__init__.py
@@ -49,6 +49,7 @@ from homeassistant.const import ( # noqa: F401
STATE_OFF,
STATE_PLAYING,
STATE_STANDBY,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
@@ -543,7 +544,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_entity_component_unrecorded_attributes = frozenset(
{
MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL,
- ATTR_ENTITY_PICTURE,
+ EntityStateAttribute.ENTITY_PICTURE,
MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST,
MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT,
MediaPlayerEntityStateAttribute.MEDIA_POSITION,
diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py
index 2b26f999907c..eaa289640e1f 100644
--- a/homeassistant/components/media_player/browse_media.py
+++ b/homeassistant/components/media_player/browse_media.py
@@ -174,7 +174,7 @@ class SearchMedia:
"""Represent search results."""
version: int = field(default=1)
- result: list[BrowseMedia]
+ result: Sequence[BrowseMedia]
def as_dict(self, *, parent: bool = True) -> dict[str, Any]:
"""Convert SearchMedia class to browse media dictionary."""
diff --git a/homeassistant/components/media_player/condition.py b/homeassistant/components/media_player/condition.py
index d95fedf92cb2..639466a0dde9 100644
--- a/homeassistant/components/media_player/condition.py
+++ b/homeassistant/components/media_player/condition.py
@@ -12,11 +12,10 @@ from homeassistant.helpers.condition import (
make_entity_state_condition,
)
-from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED
-from .const import DOMAIN, MediaPlayerState
+from .const import DOMAIN, MediaPlayerEntityStateAttribute, MediaPlayerState
VOLUME_DOMAIN_SPECS: dict[str, DomainSpec] = {
- DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL),
+ DOMAIN: DomainSpec(value_source=MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL),
}
@@ -38,8 +37,10 @@ class _MediaPlayerMutedConditionBase(EntityConditionBase):
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
- state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
- or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
+ state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED)
+ is not None
+ or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL)
+ is not None
)
@override
@@ -50,8 +51,10 @@ class _MediaPlayerMutedConditionBase(EntityConditionBase):
def _is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
- state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
- or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
+ state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED)
+ is True
+ or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL)
+ == 0
)
@override
@@ -96,7 +99,8 @@ class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase):
"""Skip media players that do not expose a volume_level attribute."""
return (
super()._should_include(state)
- and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
+ and state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL)
+ is not None
)
diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json
index 789c2c064f5f..fad4025e6f7f 100644
--- a/homeassistant/components/media_player/icons.json
+++ b/homeassistant/components/media_player/icons.json
@@ -113,7 +113,7 @@
"service": "mdi:shuffle"
},
"toggle": {
- "service": "mdi:play-pause"
+ "service": "mdi:power"
},
"turn_off": {
"service": "mdi:power"
diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py
new file mode 100644
index 000000000000..aa6778835a64
--- /dev/null
+++ b/homeassistant/components/media_player/llm.py
@@ -0,0 +1,58 @@
+"""LLM tools for the media_player integration."""
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from .const import (
+ DOMAIN,
+ INTENT_MEDIA_NEXT,
+ INTENT_MEDIA_PAUSE,
+ INTENT_MEDIA_PREVIOUS,
+ INTENT_MEDIA_SEARCH_AND_PLAY,
+ INTENT_MEDIA_UNPAUSE,
+ INTENT_PLAYER_MUTE,
+ INTENT_PLAYER_UNMUTE,
+ INTENT_SET_VOLUME,
+ INTENT_SET_VOLUME_RELATIVE,
+)
+
+# Intents owned by this integration that are exposed as LLM tools.
+LLM_INTENTS = (
+ INTENT_MEDIA_NEXT,
+ INTENT_MEDIA_PAUSE,
+ INTENT_PLAYER_MUTE,
+ INTENT_PLAYER_UNMUTE,
+ INTENT_MEDIA_PREVIOUS,
+ INTENT_MEDIA_SEARCH_AND_PLAY,
+ INTENT_MEDIA_UNPAUSE,
+ INTENT_SET_VOLUME,
+ INTENT_SET_VOLUME_RELATIVE,
+)
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return LLM tools for the integration's intents when its domain is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ if not llm_context.assistant:
+ return None
+
+ if not any(
+ async_should_expose(hass, llm_context.assistant, state.entity_id)
+ for state in hass.states.async_all(DOMAIN)
+ ):
+ return None
+
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type in LLM_INTENTS
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py
index b4c2c4f821fb..a498966c2c9b 100644
--- a/homeassistant/components/media_player/reproduce_state.py
+++ b/homeassistant/components/media_player/reproduce_state.py
@@ -5,7 +5,6 @@ from collections.abc import Iterable
from typing import Any
from homeassistant.const import (
- ATTR_SUPPORTED_FEATURES,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_STOP,
@@ -19,6 +18,7 @@ from homeassistant.const import (
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
+ EntityStateAttribute,
)
from homeassistant.core import Context, HomeAssistant, State
@@ -34,8 +34,19 @@ from .const import (
SERVICE_SELECT_SOUND_MODE,
SERVICE_SELECT_SOURCE,
MediaPlayerEntityFeature,
+ MediaPlayerEntityStateAttribute,
)
+# Maps a state attribute to the service call argument used to restore it.
+_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[MediaPlayerEntityStateAttribute, str] = {
+ MediaPlayerEntityStateAttribute.INPUT_SOURCE: ATTR_INPUT_SOURCE,
+ MediaPlayerEntityStateAttribute.SOUND_MODE: ATTR_SOUND_MODE,
+ MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: ATTR_MEDIA_VOLUME_LEVEL,
+ MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED: ATTR_MEDIA_VOLUME_MUTED,
+ MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE: ATTR_MEDIA_CONTENT_TYPE,
+ MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID: ATTR_MEDIA_CONTENT_ID,
+}
+
async def _async_reproduce_states(
hass: HomeAssistant,
@@ -46,14 +57,22 @@ async def _async_reproduce_states(
) -> None:
"""Reproduce component states."""
cur_state = hass.states.get(state.entity_id)
- features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0
+ features = (
+ cur_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES]
+ if cur_state
+ else 0
+ )
- async def call_service(service: str, keys: Iterable[str]) -> None:
- """Call service with set of attributes given."""
+ async def call_service(
+ service: str, attributes: Iterable[MediaPlayerEntityStateAttribute]
+ ) -> None:
+ """Call service with the given state attributes."""
data = {"entity_id": state.entity_id}
- for key in keys:
- if key in state.attributes:
- data[key] = state.attributes[key]
+ for attribute in attributes:
+ if attribute in state.attributes:
+ data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = state.attributes[
+ attribute
+ ]
await hass.services.async_call(
DOMAIN, service, data, blocking=True, context=context
@@ -79,42 +98,57 @@ async def _async_reproduce_states(
await call_service(SERVICE_TURN_ON, [])
cur_state = hass.states.get(state.entity_id)
- features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0
+ features = (
+ cur_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES]
+ if cur_state
+ else 0
+ )
# First set source & sound mode to match the saved supported features
if (
- ATTR_INPUT_SOURCE in state.attributes
+ MediaPlayerEntityStateAttribute.INPUT_SOURCE in state.attributes
and features & MediaPlayerEntityFeature.SELECT_SOURCE
):
- await call_service(SERVICE_SELECT_SOURCE, [ATTR_INPUT_SOURCE])
+ await call_service(
+ SERVICE_SELECT_SOURCE, [MediaPlayerEntityStateAttribute.INPUT_SOURCE]
+ )
if (
- ATTR_SOUND_MODE in state.attributes
+ MediaPlayerEntityStateAttribute.SOUND_MODE in state.attributes
and features & MediaPlayerEntityFeature.SELECT_SOUND_MODE
):
- await call_service(SERVICE_SELECT_SOUND_MODE, [ATTR_SOUND_MODE])
+ await call_service(
+ SERVICE_SELECT_SOUND_MODE, [MediaPlayerEntityStateAttribute.SOUND_MODE]
+ )
if (
- ATTR_MEDIA_VOLUME_LEVEL in state.attributes
+ MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL in state.attributes
and features & MediaPlayerEntityFeature.VOLUME_SET
):
- await call_service(SERVICE_VOLUME_SET, [ATTR_MEDIA_VOLUME_LEVEL])
+ await call_service(
+ SERVICE_VOLUME_SET, [MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL]
+ )
if (
- ATTR_MEDIA_VOLUME_MUTED in state.attributes
+ MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED in state.attributes
and features & MediaPlayerEntityFeature.VOLUME_MUTE
):
- await call_service(SERVICE_VOLUME_MUTE, [ATTR_MEDIA_VOLUME_MUTED])
+ await call_service(
+ SERVICE_VOLUME_MUTE, [MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED]
+ )
already_playing = False
- if (ATTR_MEDIA_CONTENT_TYPE in state.attributes) and (
- ATTR_MEDIA_CONTENT_ID in state.attributes
+ if (MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE in state.attributes) and (
+ MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID in state.attributes
):
if features & MediaPlayerEntityFeature.PLAY_MEDIA:
await call_service(
SERVICE_PLAY_MEDIA,
- [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID],
+ [
+ MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE,
+ MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID,
+ ],
)
already_playing = True
diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py
index f9d81b9135dc..c3320f3354ab 100644
--- a/homeassistant/components/media_player/significant_change.py
+++ b/homeassistant/components/media_player/significant_change.py
@@ -8,21 +8,16 @@ from homeassistant.helpers.significant_change import (
check_valid_float,
)
-from . import (
- ATTR_ENTITY_PICTURE_LOCAL,
- ATTR_MEDIA_POSITION,
- ATTR_MEDIA_POSITION_UPDATED_AT,
- ATTR_MEDIA_VOLUME_LEVEL,
- PROP_TO_ATTR,
-)
+from . import PROP_TO_ATTR
+from .const import MediaPlayerEntityStateAttribute
-INSIGNIFICANT_ATTRIBUTES: set[str] = {
- ATTR_MEDIA_POSITION,
- ATTR_MEDIA_POSITION_UPDATED_AT,
+INSIGNIFICANT_ATTRIBUTES: set[MediaPlayerEntityStateAttribute] = {
+ MediaPlayerEntityStateAttribute.MEDIA_POSITION,
+ MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT,
}
-SIGNIFICANT_ATTRIBUTES: set[str] = {
- ATTR_ENTITY_PICTURE_LOCAL,
+SIGNIFICANT_ATTRIBUTES: set[MediaPlayerEntityStateAttribute] = {
+ MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL,
*PROP_TO_ATTR.values(),
} - INSIGNIFICANT_ATTRIBUTES
@@ -49,7 +44,7 @@ def async_check_significant_change(
changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s}
for attr_name in changed_attrs:
- if attr_name != ATTR_MEDIA_VOLUME_LEVEL:
+ if attr_name != MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL:
return True
old_attr_value = old_attrs.get(attr_name)
diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py
index 36c9e1c936ff..0629797de740 100644
--- a/homeassistant/components/media_player/trigger.py
+++ b/homeassistant/components/media_player/trigger.py
@@ -14,11 +14,11 @@ from homeassistant.helpers.trigger import (
make_entity_transition_trigger,
)
-from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState
-from .const import DOMAIN
+from . import MediaPlayerState
+from .const import DOMAIN, MediaPlayerEntityStateAttribute
VOLUME_DOMAIN_SPECS: dict[str, DomainSpec] = {
- DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL),
+ DOMAIN: DomainSpec(value_source=MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL),
}
@@ -31,8 +31,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
- state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
- or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
+ state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED)
+ is not None
+ or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL)
+ is not None
)
@override
@@ -48,8 +50,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
def is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
- state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
- or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
+ state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED)
+ is True
+ or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL)
+ == 0
)
@override
@@ -109,7 +113,8 @@ class VolumeTriggerMixin(EntityNumericalStateTriggerBase):
"""
return (
super()._should_include(state)
- and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
+ and state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL)
+ is not None
)
diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py
index e2d30db21004..bc68335fcad3 100644
--- a/homeassistant/components/media_source/__init__.py
+++ b/homeassistant/components/media_source/__init__.py
@@ -5,22 +5,21 @@ from typing import Protocol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.integration_platform import (
- async_process_integration_platforms,
-)
+from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms
from homeassistant.helpers.typing import ConfigType
from . import http, local_source
from .const import (
+ DATA_LOCAL_SOURCE,
+ DATA_MEDIA_SOURCE_PLATFORMS,
DOMAIN,
MEDIA_CLASS_MAP,
MEDIA_MIME_TYPES,
- MEDIA_SOURCE_DATA,
URI_SCHEME,
URI_SCHEME_REGEX,
)
from .error import MediaSourceError, Unresolvable
-from .helper import async_browse_media, async_resolve_media
+from .helper import async_browse_media, async_resolve_media, async_search_media
from .models import (
BrowseMediaSource,
MediaSource,
@@ -42,6 +41,7 @@ __all__ = [
"Unresolvable",
"async_browse_media",
"async_resolve_media",
+ "async_search_media",
"generate_media_source_id",
"is_media_source_id",
]
@@ -72,17 +72,18 @@ def generate_media_source_id(domain: str, identifier: str) -> str:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the media_source component."""
- hass.data[MEDIA_SOURCE_DATA] = {}
+ hass.data[DATA_MEDIA_SOURCE_PLATFORMS] = LazyIntegrationPlatforms[MediaSource](
+ hass, DOMAIN, _process_media_source_platform
+ )
http.async_setup(hass)
# Local sources support
- await _process_media_source_platform(hass, DOMAIN, local_source)
+ source = await local_source.async_get_media_source(hass)
+ hass.data[DATA_LOCAL_SOURCE] = source
+ hass.http.register_view(local_source.LocalMediaView(hass, source))
hass.http.register_view(local_source.UploadMediaView)
websocket_api.async_register_command(hass, local_source.websocket_remove_media)
- await async_process_integration_platforms(
- hass, DOMAIN, _process_media_source_platform
- )
return True
@@ -90,9 +91,6 @@ async def _process_media_source_platform(
hass: HomeAssistant,
domain: str,
platform: MediaSourceProtocol,
-) -> None:
+) -> MediaSource:
"""Process a media source platform."""
- source = await platform.async_get_media_source(hass)
- hass.data[MEDIA_SOURCE_DATA][domain] = source
- if isinstance(source, local_source.LocalSource):
- hass.http.register_view(local_source.LocalMediaView(hass, source))
+ return await platform.async_get_media_source(hass)
diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py
index 1e9a7cc1eaa2..e7cc4499b006 100644
--- a/homeassistant/components/media_source/const.py
+++ b/homeassistant/components/media_source/const.py
@@ -7,10 +7,15 @@ from homeassistant.components.media_player import MediaClass
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
+ from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms
+
from .models import MediaSource
DOMAIN = "media_source"
-MEDIA_SOURCE_DATA: HassKey[dict[str, MediaSource]] = HassKey(DOMAIN)
+DATA_LOCAL_SOURCE: HassKey[MediaSource] = HassKey("media_source_local_source")
+DATA_MEDIA_SOURCE_PLATFORMS: HassKey[LazyIntegrationPlatforms[MediaSource]] = HassKey(
+ "media_source_platforms"
+)
MEDIA_MIME_TYPES = ("audio", "video", "image")
MEDIA_CLASS_MAP = {
"audio": MediaClass.MUSIC,
diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py
index 0d7afc2b81c3..92eff623bc3d 100644
--- a/homeassistant/components/media_source/helper.py
+++ b/homeassistant/components/media_source/helper.py
@@ -2,18 +2,29 @@
from collections.abc import Callable
-from homeassistant.components.media_player import BrowseError, BrowseMedia
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.components.media_player import (
+ BrowseError,
+ BrowseMedia,
+ SearchMedia,
+ SearchMediaQuery,
+)
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.frame import report_usage
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
-from .const import DOMAIN, MEDIA_SOURCE_DATA
+from .const import DOMAIN
from .error import UnknownMediaSource, Unresolvable
-from .models import BrowseMediaSource, MediaSourceItem, PlayMedia, RootBrowseMediaSource
+from .models import (
+ BrowseMediaSource,
+ MediaSourceItem,
+ PlayMedia,
+ RootBrowseMediaSource,
+ _async_get_media_source,
+ _async_get_media_sources,
+)
-@callback
-def _get_media_item(
+async def _get_media_item(
hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None
) -> MediaSourceItem:
"""Return media item."""
@@ -21,10 +32,14 @@ def _get_media_item(
item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player)
else:
# We default to our own domain if its only one registered
- domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN
+ sources = await _async_get_media_sources(hass)
+ domain = None if len(sources) > 1 else DOMAIN
return MediaSourceItem(hass, domain, "", target_media_player)
- if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]:
+ if (
+ item.domain is not None
+ and await _async_get_media_source(hass, item.domain) is None
+ ):
raise UnknownMediaSource(
translation_domain=DOMAIN,
translation_key="unknown_media_source",
@@ -41,11 +56,12 @@ async def async_browse_media(
content_filter: Callable[[BrowseMedia], bool] | None = None,
) -> BrowseMediaSource | RootBrowseMediaSource:
"""Return media player browse media results."""
- if DOMAIN not in hass.data:
+ if DOMAIN not in hass.config.top_level_components:
raise BrowseError("Media Source not loaded")
try:
- item = await _get_media_item(hass, media_content_id, None).async_browse()
+ media_item = await _get_media_item(hass, media_content_id, None)
+ item = await media_item.async_browse()
except ValueError as err:
raise BrowseError(
translation_domain=DOMAIN,
@@ -67,13 +83,42 @@ async def async_browse_media(
return item
+async def async_search_media(
+ hass: HomeAssistant,
+ media_content_id: str | None,
+ query: SearchMediaQuery,
+) -> SearchMedia:
+ """Return media searched in the media source."""
+ if DOMAIN not in hass.config.top_level_components:
+ raise BrowseError("Media Source not loaded")
+
+ try:
+ media_item = await _get_media_item(hass, media_content_id, None)
+ return await media_item.async_search(query)
+ except NotImplementedError as err:
+ raise BrowseError(
+ translation_domain=DOMAIN,
+ translation_key="search_not_supported",
+ translation_placeholders={"media_content_id": str(media_content_id)},
+ ) from err
+ except ValueError as err:
+ raise BrowseError(
+ translation_domain=DOMAIN,
+ translation_key="search_media_failed",
+ translation_placeholders={
+ "media_content_id": str(media_content_id),
+ "error": str(err),
+ },
+ ) from err
+
+
async def async_resolve_media(
hass: HomeAssistant,
media_content_id: str,
target_media_player: str | None | UndefinedType = UNDEFINED,
) -> PlayMedia:
"""Get info to play media."""
- if DOMAIN not in hass.data:
+ if DOMAIN not in hass.config.top_level_components:
raise Unresolvable("Media Source not loaded")
if target_media_player is UNDEFINED:
@@ -84,7 +129,7 @@ async def async_resolve_media(
target_media_player = None
try:
- item = _get_media_item(hass, media_content_id, target_media_player)
+ item = await _get_media_item(hass, media_content_id, target_media_player)
except ValueError as err:
raise Unresolvable(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/media_source/http.py b/homeassistant/components/media_source/http.py
index c1c4882e7acf..0acec90fae88 100644
--- a/homeassistant/components/media_source/http.py
+++ b/homeassistant/components/media_source/http.py
@@ -7,20 +7,25 @@ import voluptuous as vol
from homeassistant.components import frontend, websocket_api
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
+ ATTR_MEDIA_FILTER_CLASSES,
+ ATTR_MEDIA_SEARCH_QUERY,
CONTENT_AUTH_EXPIRY_TIME,
BrowseError,
+ MediaClass,
+ SearchMediaQuery,
async_process_play_media_url,
)
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant
from .error import Unresolvable
-from .helper import async_browse_media, async_resolve_media
+from .helper import async_browse_media, async_resolve_media, async_search_media
def async_setup(hass: HomeAssistant) -> None:
"""Set up the HTTP views and WebSocket commands for media sources."""
websocket_api.async_register_command(hass, websocket_browse_media)
+ websocket_api.async_register_command(hass, websocket_search_media)
websocket_api.async_register_command(hass, websocket_resolve_media)
frontend.async_register_built_in_panel(
hass, "media-browser", "media_browser", "mdi:play-box-multiple"
@@ -48,6 +53,35 @@ async def websocket_browse_media(
connection.send_error(msg["id"], "browse_media_failed", str(err))
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "media_source/search_media",
+ vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str,
+ vol.Required(ATTR_MEDIA_SEARCH_QUERY): str,
+ vol.Optional(ATTR_MEDIA_FILTER_CLASSES): [vol.Coerce(MediaClass)],
+ }
+)
+@websocket_api.async_response
+async def websocket_search_media(
+ hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Search available media."""
+ try:
+ result = await async_search_media(
+ hass,
+ msg["media_content_id"],
+ SearchMediaQuery(
+ search_query=msg[ATTR_MEDIA_SEARCH_QUERY],
+ media_filter_classes=msg.get(ATTR_MEDIA_FILTER_CLASSES),
+ ),
+ )
+ except BrowseError as err:
+ connection.send_error(msg["id"], "search_media_failed", str(err))
+ return
+
+ connection.send_result(msg["id"], result.as_dict())
+
+
@websocket_api.websocket_command(
{
vol.Required("type"): "media_source/resolve_media",
diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py
index 3c4bd81c2aee..49fb54b225ae 100644
--- a/homeassistant/components/media_source/local_source.py
+++ b/homeassistant/components/media_source/local_source.py
@@ -13,16 +13,23 @@ import voluptuous as vol
from homeassistant.components import http, websocket_api
from homeassistant.components.http import require_admin
-from homeassistant.components.media_player import BrowseError, MediaClass
+from homeassistant.components.media_player import (
+ BrowseError,
+ BrowseMedia,
+ MediaClass,
+ SearchMedia,
+ SearchMediaQuery,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
-from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, MEDIA_SOURCE_DATA
+from .const import DATA_LOCAL_SOURCE, DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES
from .error import Unresolvable
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
MAX_UPLOAD_SIZE = 1024 * 1024 * 20
+MAX_SEARCH_RESULTS = 100
LOGGER = logging.getLogger(__name__)
@@ -175,6 +182,72 @@ class LocalSource(MediaSource):
self._browse_media, source_dir_id, location
)
+ @override
+ async def async_search_media(
+ self, item: MediaSourceItem, query: SearchMediaQuery
+ ) -> SearchMedia:
+ """Search media by file name within the local media directories."""
+ if item.identifier:
+ try:
+ source_dir_id, location = self.async_parse_identifier(item)
+ except Unresolvable as err:
+ raise BrowseError(str(err)) from err
+ search_dirs = [(source_dir_id, location)]
+ else:
+ search_dirs = [(source_dir_id, "") for source_dir_id in self.media_dirs]
+
+ return await self.hass.async_add_executor_job(
+ self._search_media, search_dirs, query
+ )
+
+ def _search_media(
+ self, search_dirs: list[tuple[str, str]], query: SearchMediaQuery
+ ) -> SearchMedia:
+ """Search media files by name (runs in the executor)."""
+ query_str = query.search_query.casefold()
+ filter_classes = set(query.media_filter_classes or ())
+ results: list[BrowseMedia] = []
+
+ for source_dir_id, location in search_dirs:
+ if len(results) >= MAX_SEARCH_RESULTS:
+ break
+ base_path = Path(self.media_dirs[source_dir_id])
+ search_path = base_path / location
+ if not search_path.is_dir():
+ continue
+
+ # Traverse lazily so MAX_SEARCH_RESULTS can short-circuit large libraries
+ for path in search_path.rglob("*"):
+ if len(results) >= MAX_SEARCH_RESULTS:
+ break
+ relative = path.relative_to(base_path)
+ if any(part.startswith(".") for part in relative.parts):
+ continue
+ if query_str not in path.name.casefold() or not path.is_file():
+ continue
+ mime_type, _ = mimetypes.guess_type(str(path))
+ if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES:
+ continue
+ media_class = MEDIA_CLASS_MAP.get(
+ mime_type.split("/")[0], MediaClass.DIRECTORY
+ )
+ if filter_classes and media_class not in filter_classes:
+ continue
+ results.append(
+ BrowseMediaSource(
+ domain=self.domain,
+ identifier=f"{source_dir_id}/{relative}",
+ media_class=media_class,
+ media_content_type=mime_type,
+ title=path.name,
+ can_play=True,
+ can_expand=False,
+ )
+ )
+
+ results.sort(key=lambda item: item.title)
+ return SearchMedia(result=results)
+
def _browse_media(
self, source_dir_id: str | None, location: str
) -> BrowseMediaSource:
@@ -197,6 +270,7 @@ class LocalSource(MediaSource):
title=self.name,
can_play=False,
can_expand=True,
+ can_search=True,
children_media_class=MediaClass.DIRECTORY,
)
@@ -255,6 +329,7 @@ class LocalSource(MediaSource):
title=title,
can_play=is_file,
can_expand=is_dir,
+ can_search=is_dir,
)
if is_file or is_child:
@@ -371,7 +446,7 @@ class UploadMediaView(http.HomeAssistantView):
if target_folder.domain != DOMAIN:
raise web.HTTPBadRequest
- source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][target_folder.domain])
+ source = cast(LocalSource, hass.data[DATA_LOCAL_SOURCE])
try:
uploaded_media_source_id = await source.async_upload_media(
target_folder, data["file"]
@@ -416,7 +491,7 @@ async def websocket_remove_media(
)
return
- source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][item.domain])
+ source = cast(LocalSource, hass.data[DATA_LOCAL_SOURCE])
try:
await source.async_delete_media(item)
diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py
index c02cee7b9b95..2a341f3d0c3b 100644
--- a/homeassistant/components/media_source/models.py
+++ b/homeassistant/components/media_source/models.py
@@ -3,16 +3,44 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
-from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.components.media_player import (
+ BrowseMedia,
+ MediaClass,
+ MediaType,
+ SearchMedia,
+ SearchMediaQuery,
+)
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.translation import async_get_cached_translations
-from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX
+from .const import (
+ DATA_LOCAL_SOURCE,
+ DATA_MEDIA_SOURCE_PLATFORMS,
+ DOMAIN,
+ URI_SCHEME,
+ URI_SCHEME_REGEX,
+)
if TYPE_CHECKING:
from pathlib import Path
+async def _async_get_media_sources(hass: HomeAssistant) -> dict[str, MediaSource]:
+ """Return all media sources, loading integration platforms on demand."""
+ sources: dict[str, MediaSource] = {DOMAIN: hass.data[DATA_LOCAL_SOURCE]}
+ sources.update(await hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platforms())
+ return sources
+
+
+async def _async_get_media_source(
+ hass: HomeAssistant, domain: str
+) -> MediaSource | None:
+ """Return the media source for a domain, loading it on demand."""
+ if domain == DOMAIN:
+ return hass.data[DATA_LOCAL_SOURCE]
+ return await hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platform(domain)
+
+
@dataclass(slots=True)
class PlayMedia:
"""Represents a playable media."""
@@ -81,6 +109,7 @@ class MediaSourceItem:
can_expand=True,
children_media_class=MediaClass.APP,
)
+ sources = await _async_get_media_sources(self.hass)
base.children = sorted(
(
BrowseMediaSource(
@@ -93,24 +122,37 @@ class MediaSourceItem:
can_play=False,
can_expand=True,
)
- for source in self.hass.data[MEDIA_SOURCE_DATA].values()
+ for source in sources.values()
),
key=lambda item: item.title,
)
return base
- return await self.async_media_source().async_browse_media(self)
+ source = await self._async_media_source()
+ return await source.async_browse_media(self)
+
+ async def async_search(self, query: SearchMediaQuery) -> SearchMedia:
+ """Search this item."""
+ # Searching the aggregate root (no specific source) is currently not supported
+ # because it would possibly returns 100s of items
+ if self.domain is None:
+ raise NotImplementedError
+
+ return await (await self._async_media_source()).async_search_media(self, query)
async def async_resolve(self) -> PlayMedia:
"""Resolve to playable item."""
- return await self.async_media_source().async_resolve_media(self)
+ source = await self._async_media_source()
+ return await source.async_resolve_media(self)
- @callback
- def async_media_source(self) -> MediaSource:
+ async def _async_media_source(self) -> MediaSource:
"""Return media source that owns this item."""
if TYPE_CHECKING:
assert self.domain is not None
- return self.hass.data[MEDIA_SOURCE_DATA][self.domain]
+ # Existence is validated by _get_media_item before browse/resolve.
+ source = await _async_get_media_source(self.hass, self.domain)
+ assert source is not None
+ return source
@classmethod
def from_uri(
@@ -144,3 +186,9 @@ class MediaSource:
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
"""Browse media."""
raise NotImplementedError
+
+ async def async_search_media(
+ self, item: MediaSourceItem, query: SearchMediaQuery
+ ) -> SearchMedia:
+ """Search media."""
+ raise NotImplementedError
diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json
index 607f48f66523..9755af5f8a49 100644
--- a/homeassistant/components/media_source/strings.json
+++ b/homeassistant/components/media_source/strings.json
@@ -4,10 +4,16 @@
},
"exceptions": {
"browse_media_failed": {
- "message": "Failed to browse media with content id {media_content_id}: {error}"
+ "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}"
+ "message": "Failed to resolve media with content ID {media_content_id}: {error}"
+ },
+ "search_media_failed": {
+ "message": "Failed to search media with content ID {media_content_id}: {error}"
+ },
+ "search_not_supported": {
+ "message": "Search is not supported for media with content ID {media_content_id}"
},
"unknown_media_source": {
"message": "Unknown media source: {domain}"
diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py
index d9e367be06d0..d0b576631bcc 100644
--- a/homeassistant/components/melcloud/config_flow.py
+++ b/homeassistant/components/melcloud/config_flow.py
@@ -12,9 +12,28 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
from .const import DOMAIN
+USER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="username")
+ ),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
+ }
+)
+
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -69,9 +88,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(
- {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
- ),
+ data_schema=USER_SCHEMA,
)
return await self._create_client(
username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD]
@@ -98,9 +115,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
step_id="reauth_confirm",
- data_schema=vol.Schema(
- {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
- ),
+ data_schema=USER_SCHEMA,
errors=errors,
)
@@ -180,7 +195,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
step_id="reconfigure",
data_schema=vol.Schema(
{
- vol.Required(CONF_PASSWORD): str,
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
}
),
errors=errors,
diff --git a/homeassistant/components/melcloud_home/__init__.py b/homeassistant/components/melcloud_home/__init__.py
index def9dc20f815..762b97846120 100644
--- a/homeassistant/components/melcloud_home/__init__.py
+++ b/homeassistant/components/melcloud_home/__init__.py
@@ -11,6 +11,7 @@ from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
+ Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]
diff --git a/homeassistant/components/melcloud_home/binary_sensor.py b/homeassistant/components/melcloud_home/binary_sensor.py
index 2543227e106a..bc27a1488093 100644
--- a/homeassistant/components/melcloud_home/binary_sensor.py
+++ b/homeassistant/components/melcloud_home/binary_sensor.py
@@ -15,6 +15,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .common import async_setup_unit_entities
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
@@ -22,83 +23,71 @@ PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
-class ATABinarySensorEntityDescription(BinarySensorEntityDescription):
- """Class to hold MELCloud Home ATA binary sensor description."""
+class MelCloudHomeBinarySensorEntityDescription[_UnitT: ATAUnit | ATWUnit](
+ BinarySensorEntityDescription
+):
+ """Class to hold MELCloud Home binary sensor description."""
- state_fn: Callable[[ATAUnit], bool | None]
+ state_fn: Callable[[_UnitT], bool | None]
-@dataclass(frozen=True, kw_only=True)
-class ATWBinarySensorEntityDescription(BinarySensorEntityDescription):
- """Class to hold MELCloud Home ATW binary sensor description."""
-
- state_fn: Callable[[ATWUnit], bool | None]
-
-
-ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = (
- ATABinarySensorEntityDescription(
- key="error",
- translation_key="error",
- device_class=BinarySensorDeviceClass.PROBLEM,
- state_fn=lambda unit: unit.is_in_error,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- ATABinarySensorEntityDescription(
- key="standby",
- translation_key="standby",
- state_fn=lambda unit: unit.in_standby_mode,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- ATABinarySensorEntityDescription(
- key="frost_protection",
- translation_key="frost_protection",
- state_fn=lambda unit: (
- unit.frost_protection.enabled if unit.frost_protection else None
+def _common_sensor_descriptions[_UnitT: ATAUnit | ATWUnit](
+ unit_type: type[_UnitT],
+) -> tuple[MelCloudHomeBinarySensorEntityDescription[_UnitT], ...]:
+ """Return the binary sensor descriptions shared by ATA and ATW units."""
+ return (
+ MelCloudHomeBinarySensorEntityDescription(
+ key="error",
+ translation_key="error",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ state_fn=lambda unit: unit.is_in_error,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- ATABinarySensorEntityDescription(
- key="overheat_protection",
- translation_key="overheat_protection",
- state_fn=lambda unit: (
- unit.overheat_protection.enabled if unit.overheat_protection else None
+ MelCloudHomeBinarySensorEntityDescription(
+ key="standby",
+ translation_key="standby",
+ state_fn=lambda unit: unit.in_standby_mode,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- ATABinarySensorEntityDescription(
- key="holiday_mode",
- translation_key="holiday_mode",
- state_fn=lambda unit: unit.holiday_mode.enabled if unit.holiday_mode else None,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
+ MelCloudHomeBinarySensorEntityDescription(
+ key="holiday_mode",
+ translation_key="holiday_mode",
+ state_fn=lambda unit: (
+ unit.holiday_mode.enabled if unit.holiday_mode else None
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MelCloudHomeBinarySensorEntityDescription(
+ key="frost_protection",
+ translation_key="frost_protection",
+ state_fn=lambda unit: (
+ unit.frost_protection.enabled if unit.frost_protection else None
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MelCloudHomeBinarySensorEntityDescription(
+ key="overheat_protection",
+ translation_key="overheat_protection",
+ state_fn=lambda unit: (
+ unit.overheat_protection.enabled if unit.overheat_protection else None
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ )
+
+
+ATA_SENSORS: tuple[MelCloudHomeBinarySensorEntityDescription[ATAUnit], ...] = (
+ *_common_sensor_descriptions(ATAUnit),
)
-ATW_SENSORS: tuple[ATWBinarySensorEntityDescription, ...] = (
- ATWBinarySensorEntityDescription(
- key="error",
- translation_key="error",
- device_class=BinarySensorDeviceClass.PROBLEM,
- state_fn=lambda unit: unit.is_in_error,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- ATWBinarySensorEntityDescription(
- key="standby",
- translation_key="standby",
- state_fn=lambda unit: unit.in_standby_mode,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- ATWBinarySensorEntityDescription(
+ATW_SENSORS: tuple[MelCloudHomeBinarySensorEntityDescription[ATWUnit], ...] = (
+ *_common_sensor_descriptions(ATWUnit),
+ MelCloudHomeBinarySensorEntityDescription(
key="forced_hot_water",
translation_key="forced_hot_water",
state_fn=lambda unit: unit.forced_hot_water_mode,
entity_category=EntityCategory.DIAGNOSTIC,
),
- ATWBinarySensorEntityDescription(
- key="holiday_mode",
- translation_key="holiday_mode",
- state_fn=lambda unit: unit.holiday_mode.enabled if unit.holiday_mode else None,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
)
@@ -108,38 +97,32 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud Home binary sensors."""
- coordinator = entry.runtime_data
- def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
- async_add_entities(
- ATABinarySensor(coordinator, entity_description, unit)
+ async_setup_unit_entities(
+ entry.runtime_data,
+ async_add_entities,
+ lambda units: (
+ ATABinarySensor(entry.runtime_data, entity_description, unit)
for entity_description in ATA_SENSORS
for unit in units
- )
-
- def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
- async_add_entities(
- ATWBinarySensor(coordinator, entity_description, unit)
+ ),
+ lambda units: (
+ ATWBinarySensor(entry.runtime_data, entity_description, unit)
for entity_description in ATW_SENSORS
for unit in units
- )
-
- coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
- coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
-
- _async_add_new_ata_units(list(coordinator.ata_units.values()))
- _async_add_new_atw_units(list(coordinator.atw_units.values()))
+ ),
+ )
class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity):
"""Representation of a MELCloud Home ATA binary sensor."""
- entity_description: ATABinarySensorEntityDescription
+ entity_description: MelCloudHomeBinarySensorEntityDescription[ATAUnit]
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
- entity_description: ATABinarySensorEntityDescription,
+ entity_description: MelCloudHomeBinarySensorEntityDescription[ATAUnit],
unit: ATAUnit,
) -> None:
"""Initialize the entity."""
@@ -157,12 +140,12 @@ class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity):
class ATWBinarySensor(MelCloudHomeATWUnitEntity, BinarySensorEntity):
"""Representation of a MELCloud Home ATW binary sensor."""
- entity_description: ATWBinarySensorEntityDescription
+ entity_description: MelCloudHomeBinarySensorEntityDescription[ATWUnit]
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
- entity_description: ATWBinarySensorEntityDescription,
+ entity_description: MelCloudHomeBinarySensorEntityDescription[ATWUnit],
unit: ATWUnit,
) -> None:
"""Initialize the entity."""
diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py
index 510881e6bdda..f7742e6b3bc6 100644
--- a/homeassistant/components/melcloud_home/climate.py
+++ b/homeassistant/components/melcloud_home/climate.py
@@ -8,7 +8,6 @@ from aiomelcloudhome import (
ATAUnit,
ATAVaneHorizontal,
ATAVaneVertical,
- ATWUnit,
ATWZoneMode,
)
@@ -21,6 +20,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .common import async_setup_unit_entities
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity
@@ -99,14 +99,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud Home climate entities from a config entry."""
- coordinator = entry.runtime_data
- def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
- async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units)
-
- def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
- async_add_entities(
- ATWZoneClimateEntity(coordinator, unit, zone_number)
+ async_setup_unit_entities(
+ entry.runtime_data,
+ async_add_entities,
+ lambda units: (ATAClimateEntity(entry.runtime_data, unit) for unit in units),
+ lambda units: (
+ ATWZoneClimateEntity(entry.runtime_data, unit, zone_number)
for unit in units
for zone_number in (
[1, 2]
@@ -114,13 +113,8 @@ async def async_setup_entry(
or (unit.capabilities is None and unit.has_zone2)
else [1]
)
- )
-
- coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
- coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
-
- _async_add_new_ata_units(list(coordinator.ata_units.values()))
- _async_add_new_atw_units(list(coordinator.atw_units.values()))
+ ),
+ )
class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
@@ -198,6 +192,42 @@ class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
"""Return the target temperature."""
return self.unit.set_temperature
+ @property
+ @override
+ def min_temp(self) -> float:
+ """Return the minimum temperature based on the current HVAC mode."""
+ capabilities = self.unit.capabilities
+ if capabilities is not None:
+ hvac_mode = self.hvac_mode
+ if hvac_mode in (HVACMode.COOL, HVACMode.DRY):
+ if capabilities.min_temp_cool is not None:
+ return capabilities.min_temp_cool
+ elif hvac_mode == HVACMode.AUTO:
+ if capabilities.min_temp_auto is not None:
+ return capabilities.min_temp_auto
+ elif hvac_mode == HVACMode.HEAT:
+ if capabilities.min_temp_heat is not None:
+ return capabilities.min_temp_heat
+ return super().min_temp
+
+ @property
+ @override
+ def max_temp(self) -> float:
+ """Return the maximum temperature based on the current HVAC mode."""
+ capabilities = self.unit.capabilities
+ if capabilities is not None:
+ hvac_mode = self.hvac_mode
+ if hvac_mode in (HVACMode.COOL, HVACMode.DRY):
+ if capabilities.max_temp_cool is not None:
+ return capabilities.max_temp_cool
+ elif hvac_mode == HVACMode.AUTO:
+ if capabilities.max_temp_auto is not None:
+ return capabilities.max_temp_auto
+ elif hvac_mode == HVACMode.HEAT:
+ if capabilities.max_temp_heat is not None:
+ return capabilities.max_temp_heat
+ return super().max_temp
+
@property
@override
def hvac_mode(self) -> HVACMode:
@@ -338,6 +368,36 @@ class ATWZoneClimateEntity(MelCloudHomeATWZoneEntity, ClimateEntity):
else self.unit.set_temperature_zone2
)
+ @property
+ @override
+ def min_temp(self) -> float:
+ """Return the minimum zone temperature."""
+ capabilities = self.unit.capabilities
+ if capabilities is not None:
+ value = (
+ capabilities.min_set_temperature_zone1
+ if self.zone_number == 1
+ else capabilities.min_set_temperature_zone2
+ )
+ if value is not None:
+ return value
+ return super().min_temp
+
+ @property
+ @override
+ def max_temp(self) -> float:
+ """Return the maximum zone temperature."""
+ capabilities = self.unit.capabilities
+ if capabilities is not None:
+ value = (
+ capabilities.max_set_temperature_zone1
+ if self.zone_number == 1
+ else capabilities.max_set_temperature_zone2
+ )
+ if value is not None:
+ return value
+ return super().max_temp
+
@property
@override
def hvac_mode(self) -> HVACMode:
diff --git a/homeassistant/components/melcloud_home/common.py b/homeassistant/components/melcloud_home/common.py
new file mode 100644
index 000000000000..4a58bba960d8
--- /dev/null
+++ b/homeassistant/components/melcloud_home/common.py
@@ -0,0 +1,75 @@
+"""Commonly shared code for the MELCloud Home integration."""
+
+from collections.abc import Callable, Coroutine, Iterable
+from typing import Any
+
+from aiomelcloudhome import (
+ ATAUnit,
+ ATWUnit,
+ MelCloudHomeAuthenticationError,
+ MelCloudHomeConnectionError,
+ MelCloudHomeTimeoutError,
+)
+
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import MelCloudHomeCoordinator
+
+
+@callback
+def async_setup_unit_entities(
+ coordinator: MelCloudHomeCoordinator,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ ata_entities_fn: Callable[[list[ATAUnit]], Iterable[Entity]],
+ atw_entities_fn: Callable[[list[ATWUnit]], Iterable[Entity]],
+) -> None:
+ """Add entities for the current units and register callbacks for new units."""
+
+ def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
+ async_add_entities(ata_entities_fn(units))
+
+ def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
+ async_add_entities(atw_entities_fn(units))
+
+ coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
+ coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
+
+ _async_add_new_ata_units(list(coordinator.ata_units.values()))
+ _async_add_new_atw_units(list(coordinator.atw_units.values()))
+
+
+async def perform_action(
+ coordinator: MelCloudHomeCoordinator,
+ coroutine: Coroutine[Any, Any, None],
+) -> None:
+ """Perform a MELCloud Home action with error handling and coordinator refresh."""
+ try:
+ await coroutine
+ except MelCloudHomeAuthenticationError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ ) from err
+ except MelCloudHomeConnectionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ ) from err
+ except MelCloudHomeTimeoutError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="timeout_connect",
+ ) from err
+ else:
+ await coordinator.async_request_refresh()
+
+
+def unit_ids(unit: ATAUnit | ATWUnit) -> dict[str, list[str]]:
+ """Return the client keyword argument selecting this unit."""
+ if isinstance(unit, ATAUnit):
+ return {"ata_unit_ids": [unit.id]}
+ return {"atw_unit_ids": [unit.id]}
diff --git a/homeassistant/components/melcloud_home/config_flow.py b/homeassistant/components/melcloud_home/config_flow.py
index 17d97ff86f7b..47edc6276519 100644
--- a/homeassistant/components/melcloud_home/config_flow.py
+++ b/homeassistant/components/melcloud_home/config_flow.py
@@ -128,6 +128,43 @@ class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="reauth_confirm",
- data_schema=STEP_USER_DATA_SCHEMA,
+ data_schema=self.add_suggested_values_to_schema(
+ STEP_USER_DATA_SCHEMA,
+ {CONF_EMAIL: reauth_entry.data[CONF_EMAIL]},
+ ),
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ errors: dict[str, str] = {}
+ reconf_entry = self._get_reconfigure_entry()
+
+ if user_input is not None:
+ errors, user_id = await self._async_validate_credentials(
+ user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
+ )
+ if not errors:
+ await self.async_set_unique_id(user_id)
+ self._abort_if_unique_id_mismatch()
+ return self.async_update_reload_and_abort(
+ reconf_entry,
+ data_updates={
+ CONF_EMAIL: user_input[CONF_EMAIL],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ },
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ STEP_USER_DATA_SCHEMA,
+ {
+ CONF_EMAIL: reconf_entry.data[CONF_EMAIL],
+ CONF_PASSWORD: reconf_entry.data[CONF_PASSWORD],
+ },
+ ),
errors=errors,
)
diff --git a/homeassistant/components/melcloud_home/const.py b/homeassistant/components/melcloud_home/const.py
index 6ce91d654e5f..6adbcd76c4ea 100644
--- a/homeassistant/components/melcloud_home/const.py
+++ b/homeassistant/components/melcloud_home/const.py
@@ -1,3 +1,8 @@
"""Constants for the MELCloud Home integration."""
DOMAIN = "melcloud_home"
+
+WEB_BASE_URL = "https://melcloudhome.com"
+
+DEVICE_ATA = "ata"
+DEVICE_ATW = "atw"
diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py
index 1f9a745ac30f..c24f246d8371 100644
--- a/homeassistant/components/melcloud_home/coordinator.py
+++ b/homeassistant/components/melcloud_home/coordinator.py
@@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util.dt import utcnow
from .const import DOMAIN
@@ -50,6 +51,8 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
self.client = client
self.ata_units: dict[str, ATAUnit] = {}
self.atw_units: dict[str, ATWUnit] = {}
+ self.ata_energy: dict[str, float | None] = {}
+ self.atw_energy: dict[str, float | None] = {}
self.known_ata: set[str] = set()
self.known_atw: set[str] = set()
self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = []
@@ -60,7 +63,7 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
current_ata = [
unit for building in data.buildings for unit in building.air_to_air_units
]
- self.ata_units = {unit.id: unit for unit in current_ata}
+
current_ata_ids = {unit.id for unit in current_ata}
self.known_ata &= current_ata_ids
new_ata_ids = current_ata_ids - self.known_ata
@@ -74,7 +77,7 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
current_atw_units = [
unit for building in data.buildings for unit in building.air_to_water_units
]
- self.atw_units = {unit.id: unit for unit in current_atw_units}
+
current_atw_ids = {unit.id for unit in current_atw_units}
self.known_atw &= current_atw_ids
new_atw_ids = current_atw_ids - self.known_atw
@@ -99,15 +102,46 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
for identifier in device.identifiers
):
_LOGGER.debug("Removing stale device: %s", device.identifiers)
- registry.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ registry.async_remove_device(device.id)
@override
async def _async_update_data(self) -> UserContext:
"""Fetch data from the MELCloud Home API."""
try:
data = await self.client.get_context()
+
+ start_of_month = utcnow().replace(
+ day=1, hour=0, minute=0, second=0, microsecond=0
+ )
+ for building in data.buildings:
+ for ata_unit in building.air_to_air_units:
+ self.ata_units[ata_unit.id] = ata_unit
+ if (
+ ata_unit.capabilities
+ and ata_unit.capabilities.has_energy_consumed_meter
+ ):
+ energy = await self.client.get_energy_telemetry(
+ ata_unit.id,
+ from_dt=start_of_month,
+ to_dt=utcnow(),
+ )
+ self.ata_energy[ata_unit.id] = sum(
+ float(e.value) for e in energy
+ )
+ for atw_unit in building.air_to_water_units:
+ self.atw_units[atw_unit.id] = atw_unit
+ if (
+ atw_unit.capabilities
+ and atw_unit.capabilities.has_energy_consumed_meter
+ ):
+ energy = await self.client.get_energy_telemetry(
+ atw_unit.id,
+ from_dt=start_of_month,
+ to_dt=utcnow(),
+ )
+ self.atw_energy[atw_unit.id] = sum(
+ float(e.value) for e in energy
+ )
except MelCloudHomeAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/melcloud_home/entity.py b/homeassistant/components/melcloud_home/entity.py
index 7bb2ac4e2c87..da84d8e9abbe 100644
--- a/homeassistant/components/melcloud_home/entity.py
+++ b/homeassistant/components/melcloud_home/entity.py
@@ -4,11 +4,12 @@ from abc import abstractmethod
from typing import override
from aiomelcloudhome import ATAUnit, ATWUnit
+from yarl import URL
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
+from .const import DEVICE_ATA, DEVICE_ATW, DOMAIN, WEB_BASE_URL
from .coordinator import MelCloudHomeCoordinator
@@ -21,6 +22,8 @@ class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]):
class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
"""Base entity for a MELCloud Home unit."""
+ _unit_type_path: str
+
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: _UnitT) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
@@ -30,6 +33,9 @@ class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
identifiers={(DOMAIN, unit.id)},
name=unit.name,
manufacturer="Mitsubishi Electric",
+ configuration_url=URL(
+ f"{WEB_BASE_URL}/{self._unit_type_path}/{unit.id}/temperature"
+ ),
)
@abstractmethod
@@ -51,6 +57,8 @@ class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]):
"""Base entity for a MELCloud Home Air-to-Air unit."""
+ _unit_type_path = DEVICE_ATA
+
@override
def _units_dict(self) -> dict[str, ATAUnit]:
"""Return ATA units dict from coordinator."""
@@ -60,6 +68,8 @@ class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]):
class MelCloudHomeATWUnitEntity(MelCloudHomeUnitEntity[ATWUnit]):
"""Base entity for a MELCloud Home Air-to-Water unit."""
+ _unit_type_path = DEVICE_ATW
+
@override
def _units_dict(self) -> dict[str, ATWUnit]:
"""Return ATW units dict from coordinator."""
diff --git a/homeassistant/components/melcloud_home/icons.json b/homeassistant/components/melcloud_home/icons.json
index 2c7d55ef5f27..dc98ac2263b0 100644
--- a/homeassistant/components/melcloud_home/icons.json
+++ b/homeassistant/components/melcloud_home/icons.json
@@ -29,6 +29,20 @@
}
}
},
+ "number": {
+ "frost_protection_max_temp": {
+ "default": "mdi:thermometer"
+ },
+ "frost_protection_min_temp": {
+ "default": "mdi:thermometer-low"
+ },
+ "overheat_protection_max_temp": {
+ "default": "mdi:thermometer-high"
+ },
+ "overheat_protection_min_temp": {
+ "default": "mdi:thermometer"
+ }
+ },
"sensor": {
"room_temperature": {
"default": "mdi:home-thermometer"
diff --git a/homeassistant/components/melcloud_home/manifest.json b/homeassistant/components/melcloud_home/manifest.json
index 0ba62597594d..63adffcef260 100644
--- a/homeassistant/components/melcloud_home/manifest.json
+++ b/homeassistant/components/melcloud_home/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiomelcloudhome"],
"quality_scale": "bronze",
- "requirements": ["aiomelcloudhome==0.1.9"]
+ "requirements": ["aiomelcloudhome==0.2.1"]
}
diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py
new file mode 100644
index 000000000000..2c78d443922a
--- /dev/null
+++ b/homeassistant/components/melcloud_home/number.py
@@ -0,0 +1,292 @@
+"""Number platform for MELCloud Home."""
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from typing import Any, override
+
+from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome
+
+from homeassistant.components.number import (
+ NumberDeviceClass,
+ NumberEntity,
+ NumberEntityDescription,
+)
+from homeassistant.const import EntityCategory, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .common import async_setup_unit_entities, perform_action, unit_ids
+from .const import DOMAIN
+from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
+from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
+
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class MelCloudHomeNumberEntityDescription[_UnitT: ATAUnit | ATWUnit](
+ NumberEntityDescription
+):
+ """Class to hold MELCloud Home number description."""
+
+ available_fn: Callable[[_UnitT], bool]
+ value_fn: Callable[[_UnitT], float | None]
+ set_value_fn: Callable[[MELCloudHome, _UnitT, float], Coroutine[Any, Any, None]]
+ validate_fn: Callable[[_UnitT, float], str | None] | None = None
+
+
+def _number_descriptions[_UnitT: ATAUnit | ATWUnit](
+ unit_type: type[_UnitT],
+ *,
+ overheat_min_temp: float,
+ overheat_max_temp: float,
+) -> tuple[MelCloudHomeNumberEntityDescription[_UnitT], ...]:
+ """Return the number descriptions for a unit type."""
+ return (
+ MelCloudHomeNumberEntityDescription(
+ key="frost_protection_min_temp",
+ translation_key="frost_protection_min_temp",
+ device_class=NumberDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=0.0,
+ native_max_value=30.0,
+ native_step=0.5,
+ available_fn=lambda unit: (
+ unit.frost_protection is not None and unit.frost_protection.enabled
+ ),
+ value_fn=lambda unit: (
+ unit.frost_protection.min if unit.frost_protection else None
+ ),
+ set_value_fn=lambda client, unit, value: client.set_frost_protection(
+ enabled=unit.frost_protection.enabled
+ if unit.frost_protection
+ else False,
+ min_temp=value,
+ max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
+ **unit_ids(unit),
+ ),
+ validate_fn=lambda unit, value: (
+ "temperature_min_exceeds_max"
+ if unit.frost_protection and value >= unit.frost_protection.max
+ else None
+ ),
+ ),
+ MelCloudHomeNumberEntityDescription(
+ key="frost_protection_max_temp",
+ translation_key="frost_protection_max_temp",
+ device_class=NumberDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=0.0,
+ native_max_value=30.0,
+ native_step=0.5,
+ available_fn=lambda unit: (
+ unit.frost_protection is not None and unit.frost_protection.enabled
+ ),
+ value_fn=lambda unit: (
+ unit.frost_protection.max if unit.frost_protection else None
+ ),
+ set_value_fn=lambda client, unit, value: client.set_frost_protection(
+ enabled=unit.frost_protection.enabled
+ if unit.frost_protection
+ else False,
+ min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
+ max_temp=value,
+ **unit_ids(unit),
+ ),
+ validate_fn=lambda unit, value: (
+ "temperature_max_below_min"
+ if unit.frost_protection and value <= unit.frost_protection.min
+ else None
+ ),
+ ),
+ MelCloudHomeNumberEntityDescription(
+ key="overheat_protection_min_temp",
+ translation_key="overheat_protection_min_temp",
+ device_class=NumberDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=overheat_min_temp,
+ native_max_value=overheat_max_temp,
+ native_step=0.5,
+ available_fn=lambda unit: (
+ unit.overheat_protection is not None
+ and unit.overheat_protection.enabled
+ ),
+ value_fn=lambda unit: (
+ unit.overheat_protection.min if unit.overheat_protection else None
+ ),
+ set_value_fn=lambda client, unit, value: client.set_overheat_protection(
+ enabled=unit.overheat_protection.enabled
+ if unit.overheat_protection
+ else False,
+ min_temp=value,
+ max_temp=unit.overheat_protection.max
+ if unit.overheat_protection
+ else 0.0,
+ **unit_ids(unit),
+ ),
+ validate_fn=lambda unit, value: (
+ "temperature_min_exceeds_max"
+ if unit.overheat_protection and value >= unit.overheat_protection.max
+ else None
+ ),
+ ),
+ MelCloudHomeNumberEntityDescription(
+ key="overheat_protection_max_temp",
+ translation_key="overheat_protection_max_temp",
+ device_class=NumberDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=overheat_min_temp,
+ native_max_value=overheat_max_temp,
+ native_step=0.5,
+ available_fn=lambda unit: (
+ unit.overheat_protection is not None
+ and unit.overheat_protection.enabled
+ ),
+ value_fn=lambda unit: (
+ unit.overheat_protection.max if unit.overheat_protection else None
+ ),
+ set_value_fn=lambda client, unit, value: client.set_overheat_protection(
+ enabled=unit.overheat_protection.enabled
+ if unit.overheat_protection
+ else False,
+ min_temp=unit.overheat_protection.min
+ if unit.overheat_protection
+ else 0.0,
+ max_temp=value,
+ **unit_ids(unit),
+ ),
+ validate_fn=lambda unit, value: (
+ "temperature_max_below_min"
+ if unit.overheat_protection and value <= unit.overheat_protection.min
+ else None
+ ),
+ ),
+ )
+
+
+ATA_NUMBERS: tuple[MelCloudHomeNumberEntityDescription[ATAUnit], ...] = (
+ _number_descriptions(ATAUnit, overheat_min_temp=31.0, overheat_max_temp=40.0)
+)
+ATW_NUMBERS: tuple[MelCloudHomeNumberEntityDescription[ATWUnit], ...] = (
+ _number_descriptions(ATWUnit, overheat_min_temp=20.0, overheat_max_temp=60.0)
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: MelCloudHomeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up MELCloud Home numbers."""
+
+ async_setup_unit_entities(
+ entry.runtime_data,
+ async_add_entities,
+ lambda units: (
+ ATANumber(entry.runtime_data, entity_description, unit)
+ for entity_description in ATA_NUMBERS
+ for unit in units
+ ),
+ lambda units: (
+ ATWNumber(entry.runtime_data, entity_description, unit)
+ for entity_description in ATW_NUMBERS
+ for unit in units
+ ),
+ )
+
+
+class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity):
+ """Representation of a MELCloud Home ATA number."""
+
+ entity_description: MelCloudHomeNumberEntityDescription[ATAUnit]
+
+ def __init__(
+ self,
+ coordinator: MelCloudHomeCoordinator,
+ entity_description: MelCloudHomeNumberEntityDescription[ATAUnit],
+ unit: ATAUnit,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator, unit)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{unit.id}_{entity_description.key}"
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return if the entity is available."""
+ return super().available and self.entity_description.available_fn(self.unit)
+
+ @property
+ @override
+ def native_value(self) -> float | None:
+ """Return the current value."""
+ return self.entity_description.value_fn(self.unit)
+
+ @override
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the protection temperature threshold."""
+ if self.entity_description.validate_fn and (
+ error_key := self.entity_description.validate_fn(self.unit, value)
+ ):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=error_key,
+ )
+ await perform_action(
+ self.coordinator,
+ self.entity_description.set_value_fn(
+ self.coordinator.client, self.unit, value
+ ),
+ )
+
+
+class ATWNumber(MelCloudHomeATWUnitEntity, NumberEntity):
+ """Representation of a MELCloud Home ATW number."""
+
+ entity_description: MelCloudHomeNumberEntityDescription[ATWUnit]
+
+ def __init__(
+ self,
+ coordinator: MelCloudHomeCoordinator,
+ entity_description: MelCloudHomeNumberEntityDescription[ATWUnit],
+ unit: ATWUnit,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator, unit)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{unit.id}_{entity_description.key}"
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return if the entity is available."""
+ return super().available and self.entity_description.available_fn(self.unit)
+
+ @property
+ @override
+ def native_value(self) -> float | None:
+ """Return the current value."""
+ return self.entity_description.value_fn(self.unit)
+
+ @override
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the protection temperature threshold."""
+ if self.entity_description.validate_fn and (
+ error_key := self.entity_description.validate_fn(self.unit, value)
+ ):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=error_key,
+ )
+ await perform_action(
+ self.coordinator,
+ self.entity_description.set_value_fn(
+ self.coordinator.client, self.unit, value
+ ),
+ )
diff --git a/homeassistant/components/melcloud_home/quality_scale.yaml b/homeassistant/components/melcloud_home/quality_scale.yaml
index c5f7a70aca4c..02c57da54e40 100644
--- a/homeassistant/components/melcloud_home/quality_scale.yaml
+++ b/homeassistant/components/melcloud_home/quality_scale.yaml
@@ -64,7 +64,7 @@ rules:
entity-translations: todo
exception-translations: todo
icon-translations: todo
- reconfiguration-flow: todo
+ reconfiguration-flow: done
repair-issues: todo
stale-devices: done
diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py
index 907c7033b56f..5593ac3725cf 100644
--- a/homeassistant/components/melcloud_home/sensor.py
+++ b/homeassistant/components/melcloud_home/sensor.py
@@ -2,6 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import datetime
from typing import override
from aiomelcloudhome import ATAUnit, ATWUnit
@@ -16,11 +17,14 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
+ UnitOfEnergy,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util.dt import utcnow
+from .common import async_setup_unit_entities
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
@@ -28,82 +32,94 @@ PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
-class ATASensorEntityDescription(SensorEntityDescription):
- """Class to hold MELCloud Home ATA sensor description."""
+class MelCloudHomeSensorEntityDescription[_UnitT: ATAUnit | ATWUnit](
+ SensorEntityDescription
+):
+ """Class to hold MELCloud Home sensor description."""
- value_fn: Callable[[ATAUnit], StateType]
+ value_fn: Callable[[_UnitT, MelCloudHomeCoordinator], StateType]
+ exists_fn: Callable[[_UnitT], bool] = lambda _: True
-@dataclass(frozen=True, kw_only=True)
-class ATWSensorEntityDescription(SensorEntityDescription):
- """Class to hold MELCloud Home ATW sensor description."""
-
- value_fn: Callable[[ATWUnit], StateType]
- exists_fn: Callable[[ATWUnit], bool] = lambda unit: True
+def _common_sensor_descriptions[_UnitT: ATAUnit | ATWUnit](
+ unit_type: type[_UnitT],
+) -> tuple[MelCloudHomeSensorEntityDescription[_UnitT], ...]:
+ """Return the sensor descriptions shared by ATA and ATW units."""
+ return (
+ MelCloudHomeSensorEntityDescription(
+ key="rssi",
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=lambda unit, _: unit.rssi,
+ ),
+ MelCloudHomeSensorEntityDescription(
+ key="energy_consumed",
+ translation_key="energy_consumed",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda unit, coordinator: (
+ coordinator.ata_energy
+ if isinstance(unit, ATAUnit)
+ else coordinator.atw_energy
+ ).get(unit.id),
+ exists_fn=lambda unit: bool(
+ unit.capabilities and unit.capabilities.has_energy_consumed_meter
+ ),
+ ),
+ )
-ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = (
- ATASensorEntityDescription(
+ATA_SENSORS: tuple[MelCloudHomeSensorEntityDescription[ATAUnit], ...] = (
+ MelCloudHomeSensorEntityDescription(
key="room_temperature",
translation_key="room_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
- value_fn=lambda unit: unit.room_temperature,
- ),
- ATASensorEntityDescription(
- key="rssi",
- device_class=SensorDeviceClass.SIGNAL_STRENGTH,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- value_fn=lambda unit: unit.rssi,
+ value_fn=lambda unit, _: unit.room_temperature,
),
+ *_common_sensor_descriptions(ATAUnit),
)
-ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = (
- ATWSensorEntityDescription(
+ATW_SENSORS: tuple[MelCloudHomeSensorEntityDescription[ATWUnit], ...] = (
+ MelCloudHomeSensorEntityDescription(
key="room_temperature_zone_1",
translation_key="room_temperature_zone_1",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
- value_fn=lambda unit: unit.room_temperature_zone1,
+ value_fn=lambda unit, _: unit.room_temperature_zone1,
),
- ATWSensorEntityDescription(
+ MelCloudHomeSensorEntityDescription(
key="room_temperature_zone_2",
translation_key="room_temperature_zone_2",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
- value_fn=lambda unit: unit.room_temperature_zone2,
+ value_fn=lambda unit, _: unit.room_temperature_zone2,
exists_fn=lambda unit: bool(
(unit.capabilities and unit.capabilities.has_zone2)
or (unit.capabilities is None and unit.has_zone2)
),
),
- ATWSensorEntityDescription(
+ MelCloudHomeSensorEntityDescription(
key="tank_water_temperature",
translation_key="tank_water_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
- value_fn=lambda unit: unit.tank_water_temperature,
- ),
- ATWSensorEntityDescription(
- key="rssi",
- device_class=SensorDeviceClass.SIGNAL_STRENGTH,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- value_fn=lambda unit: unit.rssi,
+ value_fn=lambda unit, _: unit.tank_water_temperature,
),
+ *_common_sensor_descriptions(ATWUnit),
)
@@ -113,39 +129,34 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud Home sensors."""
- coordinator = entry.runtime_data
- def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
- async_add_entities(
- ATASensor(coordinator, entity_description, unit)
+ async_setup_unit_entities(
+ entry.runtime_data,
+ async_add_entities,
+ lambda units: (
+ ATASensor(entry.runtime_data, entity_description, unit)
for entity_description in ATA_SENSORS
for unit in units
- )
-
- def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
- async_add_entities(
- ATWSensor(coordinator, entity_description, unit)
+ if entity_description.exists_fn(unit)
+ ),
+ lambda units: (
+ ATWSensor(entry.runtime_data, entity_description, unit)
for entity_description in ATW_SENSORS
for unit in units
if entity_description.exists_fn(unit)
- )
-
- coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
- coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
-
- _async_add_new_ata_units(list(coordinator.ata_units.values()))
- _async_add_new_atw_units(list(coordinator.atw_units.values()))
+ ),
+ )
class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity):
"""Representation of a MELCloud Home ATA sensor."""
- entity_description: ATASensorEntityDescription
+ entity_description: MelCloudHomeSensorEntityDescription[ATAUnit]
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
- entity_description: ATASensorEntityDescription,
+ entity_description: MelCloudHomeSensorEntityDescription[ATAUnit],
unit: ATAUnit,
) -> None:
"""Initialize the entity."""
@@ -157,18 +168,26 @@ class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity):
@override
def native_value(self) -> StateType:
"""Return the state of the sensor."""
- return self.entity_description.value_fn(self.unit)
+ return self.entity_description.value_fn(self.unit, self.coordinator)
+
+ @property
+ @override
+ def last_reset(self) -> datetime | None:
+ """Return start of month for TOTAL energy sensors."""
+ if self.entity_description.state_class == SensorStateClass.TOTAL:
+ return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ return None
class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity):
"""Representation of a MELCloud Home ATW sensor."""
- entity_description: ATWSensorEntityDescription
+ entity_description: MelCloudHomeSensorEntityDescription[ATWUnit]
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
- entity_description: ATWSensorEntityDescription,
+ entity_description: MelCloudHomeSensorEntityDescription[ATWUnit],
unit: ATWUnit,
) -> None:
"""Initialize the entity."""
@@ -180,4 +199,12 @@ class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity):
@override
def native_value(self) -> StateType:
"""Return the state of the sensor."""
- return self.entity_description.value_fn(self.unit)
+ return self.entity_description.value_fn(self.unit, self.coordinator)
+
+ @property
+ @override
+ def last_reset(self) -> datetime | None:
+ """Return start of month for TOTAL energy sensors."""
+ if self.entity_description.state_class == SensorStateClass.TOTAL:
+ return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ return None
diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json
index b5eadcc104cf..330bfd52224a 100644
--- a/homeassistant/components/melcloud_home/strings.json
+++ b/homeassistant/components/melcloud_home/strings.json
@@ -3,6 +3,7 @@
"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::reauth_successful%]",
"unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account."
},
"error": {
@@ -24,6 +25,17 @@
"description": "The credentials for your MELCloud Home account are no longer valid. Enter your current credentials to reauthenticate.",
"title": "[%key:common::config_flow::title::reauth%]"
},
+ "reconfigure": {
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "[%key:component::melcloud_home::config::step::user::data_description::email%]",
+ "password": "[%key:component::melcloud_home::config::step::user::data_description::password%]"
+ },
+ "description": "Re-enter your credentials to reconfigure your MELCloud Home account."
+ },
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
@@ -96,7 +108,24 @@
}
}
},
+ "number": {
+ "frost_protection_max_temp": {
+ "name": "Frost protection maximum temperature"
+ },
+ "frost_protection_min_temp": {
+ "name": "Frost protection minimum temperature"
+ },
+ "overheat_protection_max_temp": {
+ "name": "Overheat protection maximum temperature"
+ },
+ "overheat_protection_min_temp": {
+ "name": "Overheat protection minimum temperature"
+ }
+ },
"sensor": {
+ "energy_consumed": {
+ "name": "Energy consumed (monthly)"
+ },
"room_temperature": {
"name": "Room temperature"
},
@@ -126,6 +155,12 @@
"invalid_auth": {
"message": "An error occurred while trying to authenticate"
},
+ "temperature_max_below_min": {
+ "message": "The maximum temperature must be higher than the minimum temperature."
+ },
+ "temperature_min_exceeds_max": {
+ "message": "The minimum temperature must be lower than the maximum temperature."
+ },
"timeout_connect": {
"message": "Timeout while communicating with MELCloud Home API"
}
diff --git a/homeassistant/components/melcloud_home/switch.py b/homeassistant/components/melcloud_home/switch.py
index 75301cc5ddad..2d0f6ab230e0 100644
--- a/homeassistant/components/melcloud_home/switch.py
+++ b/homeassistant/components/melcloud_home/switch.py
@@ -5,11 +5,6 @@ from dataclasses import dataclass
from typing import Any, override
from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome
-from aiomelcloudhome.exceptions import (
- MelCloudHomeAuthenticationError,
- MelCloudHomeConnectionError,
- MelCloudHomeTimeoutError,
-)
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -18,10 +13,9 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
+from .common import async_setup_unit_entities, perform_action, unit_ids
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
@@ -29,184 +23,117 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
-class ATASwitchEntityDescription(SwitchEntityDescription):
- """Class to hold MELCloud Home ATA switch description."""
+class MelCloudHomeSwitchEntityDescription[_UnitT: ATAUnit | ATWUnit](
+ SwitchEntityDescription
+):
+ """Class to hold MELCloud Home switch description."""
- available_fn: Callable[[ATAUnit], bool]
- is_on_fn: Callable[[ATAUnit], bool | None]
- turn_on_fn: Callable[[MELCloudHome, ATAUnit], Coroutine[Any, Any, None]]
- turn_off_fn: Callable[[MELCloudHome, ATAUnit], Coroutine[Any, Any, None]]
+ available_fn: Callable[[_UnitT], bool]
+ is_on_fn: Callable[[_UnitT], bool | None]
+ turn_on_fn: Callable[[MELCloudHome, _UnitT], Coroutine[Any, Any, None]]
+ turn_off_fn: Callable[[MELCloudHome, _UnitT], Coroutine[Any, Any, None]]
-@dataclass(frozen=True, kw_only=True)
-class ATWSwitchEntityDescription(SwitchEntityDescription):
- """Class to hold MELCloud Home ATW switch description."""
-
- available_fn: Callable[[ATWUnit], bool]
- is_on_fn: Callable[[ATWUnit], bool | None]
- turn_on_fn: Callable[[MELCloudHome, ATWUnit], Coroutine[Any, Any, None]]
- turn_off_fn: Callable[[MELCloudHome, ATWUnit], Coroutine[Any, Any, None]]
+def _switch_descriptions[_UnitT: ATAUnit | ATWUnit](
+ unit_type: type[_UnitT],
+) -> tuple[MelCloudHomeSwitchEntityDescription[_UnitT], ...]:
+ """Return the switch descriptions for a unit type."""
+ return (
+ MelCloudHomeSwitchEntityDescription(
+ key="frost_protection",
+ translation_key="frost_protection",
+ device_class=SwitchDeviceClass.SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ available_fn=lambda unit: unit.frost_protection is not None,
+ is_on_fn=lambda unit: (
+ unit.frost_protection.enabled if unit.frost_protection else None
+ ),
+ turn_on_fn=lambda client, unit: client.set_frost_protection(
+ enabled=True,
+ min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
+ max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
+ **unit_ids(unit),
+ ),
+ turn_off_fn=lambda client, unit: client.set_frost_protection(
+ enabled=False,
+ min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
+ max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
+ **unit_ids(unit),
+ ),
+ ),
+ MelCloudHomeSwitchEntityDescription(
+ key="overheat_protection",
+ translation_key="overheat_protection",
+ device_class=SwitchDeviceClass.SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ available_fn=lambda unit: unit.overheat_protection is not None,
+ is_on_fn=lambda unit: (
+ unit.overheat_protection.enabled if unit.overheat_protection else None
+ ),
+ turn_on_fn=lambda client, unit: client.set_overheat_protection(
+ enabled=True,
+ min_temp=unit.overheat_protection.min
+ if unit.overheat_protection
+ else 0.0,
+ max_temp=unit.overheat_protection.max
+ if unit.overheat_protection
+ else 0.0,
+ **unit_ids(unit),
+ ),
+ turn_off_fn=lambda client, unit: client.set_overheat_protection(
+ enabled=False,
+ min_temp=unit.overheat_protection.min
+ if unit.overheat_protection
+ else 0.0,
+ max_temp=unit.overheat_protection.max
+ if unit.overheat_protection
+ else 0.0,
+ **unit_ids(unit),
+ ),
+ ),
+ )
-ATA_SWITCHES: tuple[ATASwitchEntityDescription, ...] = (
- ATASwitchEntityDescription(
- key="frost_protection",
- translation_key="frost_protection",
- device_class=SwitchDeviceClass.SWITCH,
- entity_category=EntityCategory.CONFIG,
- available_fn=lambda unit: unit.frost_protection is not None,
- is_on_fn=lambda unit: (
- unit.frost_protection.enabled if unit.frost_protection else None
- ),
- turn_on_fn=lambda client, unit: client.set_frost_protection(
- enabled=True,
- min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
- max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
- ata_unit_ids=[unit.id],
- ),
- turn_off_fn=lambda client, unit: client.set_frost_protection(
- enabled=False,
- min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
- max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
- ata_unit_ids=[unit.id],
- ),
- ),
- ATASwitchEntityDescription(
- key="overheat_protection",
- translation_key="overheat_protection",
- device_class=SwitchDeviceClass.SWITCH,
- entity_category=EntityCategory.CONFIG,
- available_fn=lambda unit: unit.overheat_protection is not None,
- is_on_fn=lambda unit: (
- unit.overheat_protection.enabled if unit.overheat_protection else None
- ),
- turn_on_fn=lambda client, unit: client.set_overheat_protection(
- enabled=True,
- min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0,
- max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0,
- ata_unit_ids=[unit.id],
- ),
- turn_off_fn=lambda client, unit: client.set_overheat_protection(
- enabled=False,
- min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0,
- max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0,
- ata_unit_ids=[unit.id],
- ),
- ),
+ATA_SWITCHES: tuple[MelCloudHomeSwitchEntityDescription[ATAUnit], ...] = (
+ _switch_descriptions(ATAUnit)
)
-
-ATW_SWITCHES: tuple[ATWSwitchEntityDescription, ...] = (
- ATWSwitchEntityDescription(
- key="frost_protection",
- translation_key="frost_protection",
- device_class=SwitchDeviceClass.SWITCH,
- entity_category=EntityCategory.CONFIG,
- available_fn=lambda unit: unit.frost_protection is not None,
- is_on_fn=lambda unit: (
- unit.frost_protection.enabled if unit.frost_protection else None
- ),
- turn_on_fn=lambda client, unit: client.set_frost_protection(
- enabled=True,
- min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
- max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
- atw_unit_ids=[unit.id],
- ),
- turn_off_fn=lambda client, unit: client.set_frost_protection(
- enabled=False,
- min_temp=unit.frost_protection.min if unit.frost_protection else 0.0,
- max_temp=unit.frost_protection.max if unit.frost_protection else 0.0,
- atw_unit_ids=[unit.id],
- ),
- ),
- ATWSwitchEntityDescription(
- key="overheat_protection",
- translation_key="overheat_protection",
- device_class=SwitchDeviceClass.SWITCH,
- entity_category=EntityCategory.CONFIG,
- available_fn=lambda unit: unit.overheat_protection is not None,
- is_on_fn=lambda unit: (
- unit.overheat_protection.enabled if unit.overheat_protection else None
- ),
- turn_on_fn=lambda client, unit: client.set_overheat_protection(
- enabled=True,
- min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0,
- max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0,
- atw_unit_ids=[unit.id],
- ),
- turn_off_fn=lambda client, unit: client.set_overheat_protection(
- enabled=False,
- min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0,
- max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0,
- atw_unit_ids=[unit.id],
- ),
- ),
+ATW_SWITCHES: tuple[MelCloudHomeSwitchEntityDescription[ATWUnit], ...] = (
+ _switch_descriptions(ATWUnit)
)
-async def _perform_action(
- coordinator: MelCloudHomeCoordinator,
- coroutine: Coroutine[Any, Any, None],
-) -> None:
- """Perform a MELCloud Home action with error handling and coordinator refresh."""
- try:
- await coroutine
- except MelCloudHomeAuthenticationError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="invalid_auth",
- ) from err
- except MelCloudHomeConnectionError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="cannot_connect",
- ) from err
- except MelCloudHomeTimeoutError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="timeout_connect",
- ) from err
- else:
- await coordinator.async_request_refresh()
-
-
async def async_setup_entry(
hass: HomeAssistant,
entry: MelCloudHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud Home switches."""
- coordinator = entry.runtime_data
- def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
- async_add_entities(
- ATASwitch(coordinator, entity_description, unit)
+ async_setup_unit_entities(
+ entry.runtime_data,
+ async_add_entities,
+ lambda units: (
+ ATASwitch(entry.runtime_data, entity_description, unit)
for entity_description in ATA_SWITCHES
for unit in units
- )
-
- def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
- async_add_entities(
- ATWSwitch(coordinator, entity_description, unit)
+ ),
+ lambda units: (
+ ATWSwitch(entry.runtime_data, entity_description, unit)
for entity_description in ATW_SWITCHES
for unit in units
- )
-
- coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
- coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
-
- _async_add_new_ata_units(list(coordinator.ata_units.values()))
- _async_add_new_atw_units(list(coordinator.atw_units.values()))
+ ),
+ )
class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity):
"""Representation of a MELCloud Home ATA switch."""
- entity_description: ATASwitchEntityDescription
+ entity_description: MelCloudHomeSwitchEntityDescription[ATAUnit]
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
- entity_description: ATASwitchEntityDescription,
+ entity_description: MelCloudHomeSwitchEntityDescription[ATAUnit],
unit: ATAUnit,
) -> None:
"""Initialize the entity."""
@@ -229,7 +156,7 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity):
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable the protection."""
- await _perform_action(
+ await perform_action(
self.coordinator,
self.entity_description.turn_on_fn(self.coordinator.client, self.unit),
)
@@ -237,7 +164,7 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity):
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable the protection."""
- await _perform_action(
+ await perform_action(
self.coordinator,
self.entity_description.turn_off_fn(self.coordinator.client, self.unit),
)
@@ -246,12 +173,12 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity):
class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity):
"""Representation of a MELCloud Home ATW switch."""
- entity_description: ATWSwitchEntityDescription
+ entity_description: MelCloudHomeSwitchEntityDescription[ATWUnit]
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
- entity_description: ATWSwitchEntityDescription,
+ entity_description: MelCloudHomeSwitchEntityDescription[ATWUnit],
unit: ATWUnit,
) -> None:
"""Initialize the entity."""
@@ -274,7 +201,7 @@ class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity):
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable the protection."""
- await _perform_action(
+ await perform_action(
self.coordinator,
self.entity_description.turn_on_fn(self.coordinator.client, self.unit),
)
@@ -282,7 +209,7 @@ class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity):
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable the protection."""
- await _perform_action(
+ await perform_action(
self.coordinator,
self.entity_description.turn_off_fn(self.coordinator.client, self.unit),
)
diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py
index f4025bf10079..43e32e55d571 100644
--- a/homeassistant/components/mikrotik/__init__.py
+++ b/homeassistant/components/mikrotik/__init__.py
@@ -16,7 +16,10 @@ from .coordinator import (
mikrotik_config_entry_errors,
)
-PLATFORMS = [Platform.DEVICE_TRACKER]
+PLATFORMS = [
+ Platform.DEVICE_TRACKER,
+ Platform.SENSOR,
+]
def _call_api(data: dict[str, Any]) -> Api:
@@ -43,7 +46,7 @@ async def async_setup_entry(
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
- connections={(DOMAIN, coordinator.serial_num)},
+ identifiers={(DOMAIN, coordinator.serial_num)},
manufacturer=ATTR_MANUFACTURER,
model=coordinator.model,
name=coordinator.hostname,
diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py
index 06d14250756e..c710f66a14e3 100644
--- a/homeassistant/components/mikrotik/const.py
+++ b/homeassistant/components/mikrotik/const.py
@@ -15,36 +15,39 @@ CONF_ARP_PING: Final = "arp_ping"
CONF_FORCE_DHCP: Final = "force_dhcp"
CONF_DETECTION_TIME: Final = "detection_time"
-
NAME: Final = "name"
-INFO: Final = "info"
-IDENTITY: Final = "identity"
-ARP: Final = "arp"
+ARP: Final = "arp"
CAPSMAN: Final = "capsman"
DHCP: Final = "dhcp"
-WIRELESS: Final = "wireless"
-WIFIWAVE2: Final = "wifiwave2"
-WIFI: Final = "wifi"
-IS_WIRELESS: Final = "is_wireless"
+HEALTH: Final = "health"
+IDENTITY: Final = "identity"
+INFO: Final = "info"
IS_CAPSMAN: Final = "is_capsman"
-IS_WIFIWAVE2: Final = "is_wifiwave2"
IS_WIFI: Final = "is_wifi"
+IS_WIFIWAVE2: Final = "is_wifiwave2"
+IS_WIRELESS: Final = "is_wireless"
+SYSTEM: Final = "system"
+WIFI: Final = "wifi"
+WIFIWAVE2: Final = "wifiwave2"
+WIRELESS: Final = "wireless"
MIKROTIK_SERVICES: Final = {
ARP: "/ip/arp/getall",
CAPSMAN: "/caps-man/registration-table/getall",
DHCP: "/ip/dhcp-server/lease/getall",
+ HEALTH: "/system/health/print",
IDENTITY: "/system/identity/getall",
INFO: "/system/routerboard/getall",
- WIRELESS: "/interface/wireless/registration-table/getall",
- WIFIWAVE2: "/interface/wifiwave2/registration-table/print",
- WIFI: "/interface/wifi/registration-table/print",
- IS_WIRELESS: "/interface/wireless/print",
IS_CAPSMAN: "/caps-man/interface/print",
- IS_WIFIWAVE2: "/interface/wifiwave2/print",
IS_WIFI: "/interface/wifi/print",
+ IS_WIFIWAVE2: "/interface/wifiwave2/print",
+ IS_WIRELESS: "/interface/wireless/print",
+ SYSTEM: "/system/resource/print",
+ WIFI: "/interface/wifi/registration-table/print",
+ WIFIWAVE2: "/interface/wifiwave2/registration-table/print",
+ WIRELESS: "/interface/wireless/registration-table/getall",
}
diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py
index 902681670388..8b41b852e3be 100644
--- a/homeassistant/components/mikrotik/coordinator.py
+++ b/homeassistant/components/mikrotik/coordinator.py
@@ -30,6 +30,7 @@ from .const import (
DEFAULT_DETECTION_TIME,
DHCP,
DOMAIN,
+ HEALTH,
IDENTITY,
INFO,
IS_CAPSMAN,
@@ -38,6 +39,7 @@ from .const import (
IS_WIRELESS,
MIKROTIK_SERVICES,
NAME,
+ SYSTEM,
WIFI,
WIFIWAVE2,
WIRELESS,
@@ -72,6 +74,7 @@ class MikrotikData:
self.model: str = ""
self.firmware: str = ""
self.serial_number: str = ""
+ self.sensors: dict[str, Any] = {}
@staticmethod
def load_mac(devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
@@ -164,6 +167,13 @@ class MikrotikData:
# get new hub firmware version if updated
self.firmware = self.get_info(ATTR_FIRMWARE)
+ self.sensors[HEALTH] = (
+ self.command(MIKROTIK_SERVICES[HEALTH], suppress_errors=True) or []
+ )
+ self.sensors[SYSTEM] = (
+ self.command(MIKROTIK_SERVICES[SYSTEM], suppress_errors=True) or []
+ )
+
if not device_list:
return
@@ -224,9 +234,7 @@ class MikrotikData:
) -> list[dict[str, Any]]:
"""Retrieve data from Mikrotik API."""
_LOGGER.debug("Running command %s", cmd)
- with mikrotik_config_entry_errors(
- suppress_errors=suppress_errors, host=self._host
- ):
+ with mikrotik_config_entry_errors(suppress_errors=suppress_errors):
if params:
return list(self.api(cmd, **params))
return list(self.api(cmd))
@@ -331,7 +339,7 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api:
_error = api_error
if _error is not None:
- _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], _error)
+ _LOGGER.debug("Mikrotik %s error: %s", entry[CONF_HOST], _error)
if "invalid user name or password" in str(_error):
raise LoginError from _error
raise CannotConnect from _error
diff --git a/homeassistant/components/mikrotik/entity.py b/homeassistant/components/mikrotik/entity.py
new file mode 100644
index 000000000000..13573bbfb195
--- /dev/null
+++ b/homeassistant/components/mikrotik/entity.py
@@ -0,0 +1,43 @@
+"""Base class for Mikrotik routers entities."""
+
+from yarl import URL
+
+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 MikrotikDataUpdateCoordinator
+
+
+class MikrotikEntity[DescriptionT: EntityDescription](
+ CoordinatorEntity[MikrotikDataUpdateCoordinator]
+):
+ """Base class for Mikrotik entities."""
+
+ _attr_has_entity_name = True
+ entity_description: DescriptionT
+
+ def __init__(
+ self,
+ coordinator: MikrotikDataUpdateCoordinator,
+ description: DescriptionT,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+
+ self._serial = coordinator.api.serial_number
+ self._attr_device_info = DeviceInfo(
+ configuration_url=URL.build(
+ scheme="http",
+ host=coordinator.host,
+ ),
+ identifiers={(DOMAIN, self._serial)},
+ name=coordinator.hostname,
+ manufacturer="Mikrotik",
+ model=coordinator.model,
+ sw_version=coordinator.firmware,
+ serial_number=self._serial,
+ )
+ self._attr_unique_id = f"{self._serial}_{description.key}"
diff --git a/homeassistant/components/mikrotik/icons.json b/homeassistant/components/mikrotik/icons.json
new file mode 100644
index 000000000000..73f0b262b450
--- /dev/null
+++ b/homeassistant/components/mikrotik/icons.json
@@ -0,0 +1,15 @@
+{
+ "entity": {
+ "sensor": {
+ "cpu_load": {
+ "default": "mdi:chip"
+ },
+ "disk_usage": {
+ "default": "mdi:harddisk"
+ },
+ "memory_usage": {
+ "default": "mdi:memory"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json
index 3864af1f18c6..3234575d3277 100644
--- a/homeassistant/components/mikrotik/manifest.json
+++ b/homeassistant/components/mikrotik/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "mikrotik",
"name": "Mikrotik",
- "codeowners": ["@engrbm87"],
+ "codeowners": ["@engrbm87", "@chemelli74"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mikrotik",
"integration_type": "device",
diff --git a/homeassistant/components/mikrotik/sensor.py b/homeassistant/components/mikrotik/sensor.py
new file mode 100644
index 000000000000..fc3c1e48d1bf
--- /dev/null
+++ b/homeassistant/components/mikrotik/sensor.py
@@ -0,0 +1,179 @@
+"""Support for Mikrotik routers sensors."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any, Final, override
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import (
+ EntityCategory,
+ UnitOfElectricPotential,
+ UnitOfRatio,
+ UnitOfTemperature,
+)
+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 .const import HEALTH, SYSTEM
+from .coordinator import _LOGGER, MikrotikConfigEntry
+from .entity import MikrotikEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class MikrotikSensorEntityDescription(SensorEntityDescription):
+ """Shared Mikrotik Sensors entity description."""
+
+ value: Callable[[dict[str, Any]], StateType | datetime]
+ type: str
+ index: int
+
+
+def _calculate_uptime(data: dict[str, Any]) -> datetime | None:
+ """Calculate uptime."""
+ # e.g. 1d3h39m30s
+ uptime_string = data["uptime"]
+
+ total = 0
+ num = 0
+
+ for ch in uptime_string.strip():
+ if ch.isdigit():
+ num = num * 10 + int(ch)
+ else:
+ if ch == "w":
+ total += num * (60 * 60 * 24 * 7)
+ elif ch == "d":
+ total += num * (60 * 60 * 24)
+ elif ch == "h":
+ total += num * (60 * 60)
+ elif ch == "m":
+ total += num * 60
+ elif ch == "s":
+ total += num
+ else:
+ _LOGGER.warning("Unknown uptime format: %s", uptime_string)
+ return None
+
+ num = 0
+
+ if num != 0:
+ _LOGGER.warning("Unknown uptime format: %s", uptime_string)
+ return None
+
+ return utcnow() - timedelta(seconds=total)
+
+
+SENSORS: Final = (
+ MikrotikSensorEntityDescription(
+ key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ value=lambda _data: _data["value"],
+ type=HEALTH,
+ index=1,
+ ),
+ MikrotikSensorEntityDescription(
+ key="voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ value=lambda _data: _data["value"],
+ type=HEALTH,
+ index=0,
+ ),
+ MikrotikSensorEntityDescription(
+ key="cpu-load",
+ translation_key="cpu_load",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
+ suggested_display_precision=2,
+ value=lambda _data: _data["cpu-load"],
+ type=SYSTEM,
+ index=0,
+ ),
+ MikrotikSensorEntityDescription(
+ key="memory-usage",
+ translation_key="memory_usage",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
+ suggested_display_precision=2,
+ value=lambda _data: (
+ None
+ if (total := _data.get("total-memory", 0)) == 0
+ else (total - _data.get("free-memory", 0)) / total * 100
+ ),
+ type=SYSTEM,
+ index=0,
+ ),
+ MikrotikSensorEntityDescription(
+ key="disk-usage",
+ translation_key="disk_usage",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
+ suggested_display_precision=2,
+ value=lambda _data: (
+ None
+ if (total := _data.get("total-hdd-space", 0)) == 0
+ else (total - _data.get("free-hdd-space", 0)) / total * 100
+ ),
+ type=SYSTEM,
+ index=0,
+ ),
+ MikrotikSensorEntityDescription(
+ key="uptime",
+ device_class=SensorDeviceClass.UPTIME,
+ value=_calculate_uptime,
+ type=SYSTEM,
+ index=0,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: MikrotikConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Mikrotik sensors based on a config entry."""
+
+ coordinator = entry.runtime_data
+
+ sensors_list = [
+ MikrotikSensorEntity(coordinator, sensor_desc)
+ for sensor_desc in SENSORS
+ if len(coordinator.api.sensors.get(sensor_desc.type, []))
+ >= (sensor_desc.index + 1)
+ ]
+
+ async_add_entities(sensors_list)
+
+
+class MikrotikSensorEntity(
+ MikrotikEntity[MikrotikSensorEntityDescription], SensorEntity
+):
+ """Sensor device."""
+
+ entity_description: MikrotikSensorEntityDescription
+
+ @property
+ @override
+ def native_value(self) -> StateType | datetime:
+ """Return the state of the sensor."""
+ data_list = self.coordinator.api.sensors[self.entity_description.type]
+ data_entry = data_list[self.entity_description.index]
+
+ return self.entity_description.value(data_entry)
diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json
index 99a94d485f08..0f63b1e3ab70 100644
--- a/homeassistant/components/mikrotik/strings.json
+++ b/homeassistant/components/mikrotik/strings.json
@@ -30,6 +30,19 @@
}
}
},
+ "entity": {
+ "sensor": {
+ "cpu_load": {
+ "name": "CPU usage"
+ },
+ "disk_usage": {
+ "name": "Disk usage"
+ },
+ "memory_usage": {
+ "name": "Memory usage"
+ }
+ }
+ },
"exceptions": {
"cannot_connect": {
"message": "Error connecting: {error}"
diff --git a/homeassistant/components/mikrotik/utils.py b/homeassistant/components/mikrotik/utils.py
index 46fe6d0beff0..4bc50d05f7c1 100644
--- a/homeassistant/components/mikrotik/utils.py
+++ b/homeassistant/components/mikrotik/utils.py
@@ -16,9 +16,7 @@ from .errors import CannotConnect, LoginError
@contextmanager
-def mikrotik_config_entry_errors(
- suppress_errors: bool = False, host: str | None = None
-) -> Generator[None]:
+def mikrotik_config_entry_errors(suppress_errors: bool = False) -> Generator[None]:
"""Handle common Mikrotik API exceptions as ConfigEntry errors."""
try:
yield
diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py
index cc1f4b437012..972237a6b6ea 100644
--- a/homeassistant/components/min_max/sensor.py
+++ b/homeassistant/components/min_max/sensor.py
@@ -16,12 +16,12 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_TYPE,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -325,11 +325,11 @@ class MinMaxSensor(SensorEntity):
if self._unit_of_measurement is None:
self._unit_of_measurement = new_state.attributes.get(
- ATTR_UNIT_OF_MEASUREMENT
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
)
if self._unit_of_measurement != new_state.attributes.get(
- ATTR_UNIT_OF_MEASUREMENT
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
):
_LOGGER.warning(
"Units of measurement do not match for entity %s", self.entity_id
diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json
index c93ca805da6a..c4935aa408cb 100644
--- a/homeassistant/components/mitsubishi_comfort/manifest.json
+++ b/homeassistant/components/mitsubishi_comfort/manifest.json
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
- "requirements": ["mitsubishi-comfort==0.3.2"]
+ "requirements": ["mitsubishi-comfort==0.5.0"]
}
diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py
index 189f8d95bf4a..ce6d1774bfc2 100644
--- a/homeassistant/components/mobile_app/device_tracker.py
+++ b/homeassistant/components/mobile_app/device_tracker.py
@@ -13,6 +13,7 @@ from homeassistant.components.device_tracker import (
ATTR_IN_ZONES,
ATTR_LOCATION_NAME,
TrackerEntity,
+ TrackerEntityStateAttribute,
)
from homeassistant.components.zone import (
DOMAIN as ZONE_DOMAIN,
@@ -24,9 +25,8 @@ from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_DEVICE_ID,
ATTR_GPS_ACCURACY,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
STATE_HOME,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -221,8 +221,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
attr = state.attributes
data = {
- ATTR_GPS: (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)),
- ATTR_GPS_ACCURACY: attr.get(ATTR_GPS_ACCURACY),
+ ATTR_GPS: (
+ attr.get(EntityStateAttribute.LATITUDE),
+ attr.get(EntityStateAttribute.LONGITUDE),
+ ),
+ ATTR_GPS_ACCURACY: attr.get(TrackerEntityStateAttribute.GPS_ACCURACY),
ATTR_BATTERY: attr.get(ATTR_BATTERY_LEVEL),
}
data.update({key: attr[key] for key in attr if key in ATTR_KEYS})
diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py
index 4e89b7202fc9..b5798b0db03c 100644
--- a/homeassistant/components/mobile_app/entity.py
+++ b/homeassistant/components/mobile_app/entity.py
@@ -5,11 +5,11 @@ from typing import override
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_ICON,
CONF_NAME,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import State, callback
from homeassistant.helpers.device_registry import DeviceInfo
@@ -93,8 +93,10 @@ class MobileAppEntity(RestoreEntity):
**last_state.attributes,
**self._config[ATTR_SENSOR_ATTRIBUTES],
}
- if ATTR_ICON in last_state.attributes:
- config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON]
+ if EntityStateAttribute.ICON in last_state.attributes:
+ config[ATTR_SENSOR_ICON] = last_state.attributes[
+ EntityStateAttribute.ICON
+ ]
@property
@override
diff --git a/homeassistant/components/mobile_app/logbook.py b/homeassistant/components/mobile_app/logbook.py
index 8a36eaabd530..1a3e4827eb7a 100644
--- a/homeassistant/components/mobile_app/logbook.py
+++ b/homeassistant/components/mobile_app/logbook.py
@@ -9,7 +9,7 @@ from homeassistant.components.logbook import (
LOGBOOK_ENTRY_MESSAGE,
LOGBOOK_ENTRY_NAME,
)
-from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.util.event_type import EventType
@@ -46,8 +46,8 @@ def async_describe_events(
zone_name = None
zone_icon = None
if zone_entity_id and (zone_state := hass.states.get(zone_entity_id)):
- zone_name = zone_state.attributes.get(ATTR_FRIENDLY_NAME)
- zone_icon = zone_state.attributes.get(ATTR_ICON)
+ zone_name = zone_state.attributes.get(EntityStateAttribute.FRIENDLY_NAME)
+ zone_icon = zone_state.attributes.get(EntityStateAttribute.ICON)
description = {
LOGBOOK_ENTRY_NAME: source_device_name,
LOGBOOK_ENTRY_MESSAGE: f"{event_description} {zone_name or zone_entity_id}",
diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py
index 0121f5558ecc..c2186f772db6 100644
--- a/homeassistant/components/mobile_app/webhook.py
+++ b/homeassistant/components/mobile_app/webhook.py
@@ -35,11 +35,11 @@ from homeassistant.const import (
ATTR_MODEL,
ATTR_SERVICE,
ATTR_SERVICE_DATA,
- ATTR_SUPPORTED_FEATURES,
CONF_NAME,
CONF_UNIQUE_ID,
CONF_WEBHOOK_ID,
EntityCategory,
+ EntityStateAttribute,
)
from homeassistant.core import EventOrigin, HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
@@ -352,7 +352,10 @@ async def webhook_stream_camera(
"mjpeg_path": f"/api/camera_proxy_stream/{camera_state.entity_id}"
}
- if camera_state.attributes[ATTR_SUPPORTED_FEATURES] & CameraEntityFeature.STREAM:
+ if (
+ camera_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES]
+ & CameraEntityFeature.STREAM
+ ):
try:
resp["hls_path"] = await camera.async_request_stream(
hass, camera_state.entity_id, "hls"
diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py
index 402bf30d7a54..8d2590d442fa 100644
--- a/homeassistant/components/modbus/climate.py
+++ b/homeassistant/components/modbus/climate.py
@@ -21,6 +21,7 @@ from homeassistant.components.climate import (
SWING_VERTICAL,
ClimateEntity,
ClimateEntityFeature,
+ ClimateEntityStateAttribute,
HVACAction,
HVACMode,
)
@@ -314,8 +315,10 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
"""Handle entity which will be added."""
await self.async_base_added_to_hass()
state = await self.async_get_last_state()
- if state and state.attributes.get(ATTR_TEMPERATURE):
- self._attr_target_temperature = float(state.attributes[ATTR_TEMPERATURE])
+ if state and state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE):
+ self._attr_target_temperature = float(
+ state.attributes[ClimateEntityStateAttribute.TEMPERATURE]
+ )
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py
index a829e6432e2f..39c1c60754af 100644
--- a/homeassistant/components/modbus/light.py
+++ b/homeassistant/components/modbus/light.py
@@ -7,6 +7,7 @@ from homeassistant.components.light import (
ATTR_COLOR_TEMP_KELVIN,
ColorMode,
LightEntity,
+ LightEntityStateAttribute,
)
from homeassistant.const import CONF_LIGHTS, CONF_NAME
from homeassistant.core import HomeAssistant
@@ -79,10 +80,16 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
if (state := await self.async_get_last_state()) is None:
return
- if (brightness := state.attributes.get(ATTR_BRIGHTNESS)) is not None:
+ if (
+ brightness := state.attributes.get(LightEntityStateAttribute.BRIGHTNESS)
+ ) is not None:
self._attr_brightness = brightness
- if (color_temp := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
+ if (
+ color_temp := state.attributes.get(
+ LightEntityStateAttribute.COLOR_TEMP_KELVIN
+ )
+ ) is not None:
self._attr_color_temp_kelvin = color_temp
@staticmethod
diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json
index 7709841a3b96..30945c8a13df 100644
--- a/homeassistant/components/modbus/manifest.json
+++ b/homeassistant/components/modbus/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "modbus",
- "name": "Manual Modbus",
+ "name": "Modbus",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json
index 08d29cc9aafb..d0d78d726e05 100644
--- a/homeassistant/components/modbus/strings.json
+++ b/homeassistant/components/modbus/strings.json
@@ -90,6 +90,5 @@
},
"name": "Write register"
}
- },
- "title": "Manual Modbus"
+ }
}
diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py
index d60b5f0c696d..77bbb507849f 100644
--- a/homeassistant/components/mold_indicator/__init__.py
+++ b/homeassistant/components/mold_indicator/__init__.py
@@ -37,7 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# to the humidity sensor's device.
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py
index 47e0ceb56bed..5dae594c371e 100644
--- a/homeassistant/components/mold_indicator/sensor.py
+++ b/homeassistant/components/mold_indicator/sensor.py
@@ -16,12 +16,12 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_UNIQUE_ID,
PERCENTAGE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
UnitOfTemperature,
)
from homeassistant.core import (
@@ -298,7 +298,9 @@ class MoldIndicator(SensorEntity):
)
return None
- return validator(value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
+ return validator(
+ value, state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ )
def _get_temperature_from_state(self, state: State | None) -> float | None:
"""Get temperature value in Celsius from state."""
diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py
index a0d4d714bc8c..df2d01d96b70 100644
--- a/homeassistant/components/monarch_money/config_flow.py
+++ b/homeassistant/components/monarch_money/config_flow.py
@@ -68,9 +68,8 @@ async def validate_login(
LOGGER.debug("Attempting to authenticate with MFA code")
try:
await monarch_client.multi_factor_authenticate(email, password, mfa_code)
- except KeyError as err:
- # A bug in the backing lib that I don't control
- # throws a KeyError if the MFA code is wrong
+ except (KeyError, RequireMFAException, LoginFailedException) as err:
+ # Backing library MFA failures can surface as a KeyError or auth error.
LOGGER.debug("Bad MFA Code")
raise BadMFA from err
else:
diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json
index 7ada72badb1a..015418de814e 100644
--- a/homeassistant/components/monarch_money/manifest.json
+++ b/homeassistant/components/monarch_money/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "monarch_money",
"name": "Monarch Money",
- "codeowners": ["@jeeftor"],
+ "codeowners": ["@jeeftor", "@bradleyseanf"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/monarchmoney",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["typedmonarchmoney==0.7.0"]
+ "requirements": ["monarchmoneycommunity==1.5.1"]
}
diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py
index 7ee75fa67a9f..81749cca18c5 100644
--- a/homeassistant/components/monoprice/config_flow.py
+++ b/homeassistant/components/monoprice/config_flow.py
@@ -16,6 +16,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.selector import SerialPortSelector
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -42,7 +43,9 @@ SOURCES = [
OPTIONS_FOR_DATA: VolDictType = {vol.Optional(source): str for source in SOURCES}
-DATA_SCHEMA = vol.Schema({vol.Required(CONF_PORT): str, **OPTIONS_FOR_DATA})
+DATA_SCHEMA = vol.Schema(
+ {vol.Required(CONF_PORT): SerialPortSelector(), **OPTIONS_FOR_DATA}
+)
@callback
diff --git a/homeassistant/components/monoprice/manifest.json b/homeassistant/components/monoprice/manifest.json
index 7f4631f2aeef..25854086371b 100644
--- a/homeassistant/components/monoprice/manifest.json
+++ b/homeassistant/components/monoprice/manifest.json
@@ -3,6 +3,7 @@
"name": "Monoprice 6-Zone Amplifier",
"codeowners": ["@etsinko", "@OnFreund"],
"config_flow": true,
+ "dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/monoprice",
"integration_type": "hub",
"iot_class": "local_polling",
diff --git a/homeassistant/components/moon/const.py b/homeassistant/components/moon/const.py
index 3e926b4ff3e8..f51f80431804 100644
--- a/homeassistant/components/moon/const.py
+++ b/homeassistant/components/moon/const.py
@@ -8,3 +8,5 @@ DOMAIN: Final = "moon"
PLATFORMS: Final = [Platform.SENSOR]
DEFAULT_NAME: Final = "Moon"
+
+CONF_PHASE: Final = "phase"
diff --git a/homeassistant/components/moon/helpers.py b/homeassistant/components/moon/helpers.py
new file mode 100644
index 000000000000..dbf3b7907b3d
--- /dev/null
+++ b/homeassistant/components/moon/helpers.py
@@ -0,0 +1,48 @@
+"""Helpers for moon phases."""
+
+from astral import moon
+
+from homeassistant.core import callback
+from homeassistant.util import dt as dt_util
+
+STATE_FIRST_QUARTER = "first_quarter"
+STATE_FULL_MOON = "full_moon"
+STATE_LAST_QUARTER = "last_quarter"
+STATE_NEW_MOON = "new_moon"
+STATE_WANING_CRESCENT = "waning_crescent"
+STATE_WANING_GIBBOUS = "waning_gibbous"
+STATE_WAXING_CRESCENT = "waxing_crescent"
+STATE_WAXING_GIBBOUS = "waxing_gibbous"
+
+# The eight moon phases in chronological order (new moon to waning crescent).
+MOON_PHASES: tuple[str, ...] = (
+ STATE_NEW_MOON,
+ STATE_WAXING_CRESCENT,
+ STATE_FIRST_QUARTER,
+ STATE_WAXING_GIBBOUS,
+ STATE_FULL_MOON,
+ STATE_WANING_GIBBOUS,
+ STATE_LAST_QUARTER,
+ STATE_WANING_CRESCENT,
+)
+
+
+@callback
+def moon_phase() -> str:
+ """Return the current moon phase."""
+ value: float = moon.phase(dt_util.now().date())
+ if value < 0.5 or value > 27.5:
+ return STATE_NEW_MOON
+ if value < 6.5:
+ return STATE_WAXING_CRESCENT
+ if value < 7.5:
+ return STATE_FIRST_QUARTER
+ if value < 13.5:
+ return STATE_WAXING_GIBBOUS
+ if value < 14.5:
+ return STATE_FULL_MOON
+ if value < 20.5:
+ return STATE_WANING_GIBBOUS
+ if value < 21.5:
+ return STATE_LAST_QUARTER
+ return STATE_WANING_CRESCENT
diff --git a/homeassistant/components/moon/icons.json b/homeassistant/components/moon/icons.json
index 77c578c8f0d8..288925f28be3 100644
--- a/homeassistant/components/moon/icons.json
+++ b/homeassistant/components/moon/icons.json
@@ -15,5 +15,10 @@
}
}
}
+ },
+ "triggers": {
+ "phase_changed": {
+ "trigger": "mdi:moon-waning-crescent"
+ }
}
}
diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py
index 3f7f25eb8149..c20a0a392dc6 100644
--- a/homeassistant/components/moon/sensor.py
+++ b/homeassistant/components/moon/sensor.py
@@ -1,24 +1,13 @@
"""Support for tracking the moon phases."""
-from astral import moon
-
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 AddConfigEntryEntitiesCallback
-from homeassistant.util import dt as dt_util
from .const import DOMAIN
-
-STATE_FIRST_QUARTER = "first_quarter"
-STATE_FULL_MOON = "full_moon"
-STATE_LAST_QUARTER = "last_quarter"
-STATE_NEW_MOON = "new_moon"
-STATE_WANING_CRESCENT = "waning_crescent"
-STATE_WANING_GIBBOUS = "waning_gibbous"
-STATE_WAXING_CRESCENT = "waxing_crescent"
-STATE_WAXING_GIBBOUS = "waxing_gibbous"
+from .helpers import MOON_PHASES, moon_phase
async def async_setup_entry(
@@ -35,16 +24,7 @@ class MoonSensorEntity(SensorEntity):
_attr_has_entity_name = True
_attr_device_class = SensorDeviceClass.ENUM
- _attr_options = [
- STATE_NEW_MOON,
- STATE_WAXING_CRESCENT,
- STATE_FIRST_QUARTER,
- STATE_WAXING_GIBBOUS,
- STATE_FULL_MOON,
- STATE_WANING_GIBBOUS,
- STATE_LAST_QUARTER,
- STATE_WANING_CRESCENT,
- ]
+ _attr_options = list(MOON_PHASES)
_attr_translation_key = "phase"
def __init__(self, entry: ConfigEntry) -> None:
@@ -58,22 +38,4 @@ class MoonSensorEntity(SensorEntity):
async def async_update(self) -> None:
"""Get the time and updates the states."""
- today = dt_util.now().date()
- state = moon.phase(today)
-
- if state < 0.5 or state > 27.5:
- self._attr_native_value = STATE_NEW_MOON
- elif state < 6.5:
- self._attr_native_value = STATE_WAXING_CRESCENT
- elif state < 7.5:
- self._attr_native_value = STATE_FIRST_QUARTER
- elif state < 13.5:
- self._attr_native_value = STATE_WAXING_GIBBOUS
- elif state < 14.5:
- self._attr_native_value = STATE_FULL_MOON
- elif state < 20.5:
- self._attr_native_value = STATE_WANING_GIBBOUS
- elif state < 21.5:
- self._attr_native_value = STATE_LAST_QUARTER
- else:
- self._attr_native_value = STATE_WANING_CRESCENT
+ self._attr_native_value = moon_phase()
diff --git a/homeassistant/components/moon/strings.json b/homeassistant/components/moon/strings.json
index 8048f344c7b1..65baaed8766a 100644
--- a/homeassistant/components/moon/strings.json
+++ b/homeassistant/components/moon/strings.json
@@ -37,5 +37,32 @@
}
}
},
- "title": "Moon"
+ "selector": {
+ "phase": {
+ "options": {
+ "any": "Any",
+ "first_quarter": "[%key:component::moon::entity::sensor::phase::state::first_quarter%]",
+ "full_moon": "[%key:component::moon::entity::sensor::phase::state::full_moon%]",
+ "last_quarter": "[%key:component::moon::entity::sensor::phase::state::last_quarter%]",
+ "new_moon": "[%key:component::moon::entity::sensor::phase::state::new_moon%]",
+ "waning_crescent": "[%key:component::moon::entity::sensor::phase::state::waning_crescent%]",
+ "waning_gibbous": "[%key:component::moon::entity::sensor::phase::state::waning_gibbous%]",
+ "waxing_crescent": "[%key:component::moon::entity::sensor::phase::state::waxing_crescent%]",
+ "waxing_gibbous": "[%key:component::moon::entity::sensor::phase::state::waxing_gibbous%]"
+ }
+ }
+ },
+ "title": "Moon",
+ "triggers": {
+ "phase_changed": {
+ "description": "Triggers when the moon enters a new phase.",
+ "fields": {
+ "phase": {
+ "description": "Limit the trigger to a specific moon phase, or leave as Any to trigger on every phase change.",
+ "name": "Phase"
+ }
+ },
+ "name": "Moon phase changed"
+ }
+ }
}
diff --git a/homeassistant/components/moon/trigger.py b/homeassistant/components/moon/trigger.py
new file mode 100644
index 000000000000..174436020f9a
--- /dev/null
+++ b/homeassistant/components/moon/trigger.py
@@ -0,0 +1,88 @@
+"""Provides triggers for the moon."""
+
+from datetime import datetime
+from typing import cast, override
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_OPTIONS
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.helpers.event import async_track_time_change
+from homeassistant.helpers.trigger import (
+ Trigger,
+ TriggerActionRunner,
+ TriggerConfig,
+ TriggerNotTriggeredReporter,
+)
+from homeassistant.helpers.typing import ConfigType
+
+from .const import CONF_PHASE
+from .helpers import MOON_PHASES, moon_phase
+
+PHASE_ANY = "any"
+
+_PHASE_CHANGED_TRIGGER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_OPTIONS, default=dict): {
+ vol.Optional(CONF_PHASE, default=PHASE_ANY): vol.In(
+ [PHASE_ANY, *MOON_PHASES]
+ ),
+ }
+ }
+)
+
+
+class MoonPhaseChangedTrigger(Trigger):
+ """Trigger that fires when the moon enters a new phase."""
+
+ @override
+ @classmethod
+ async def async_validate_config(
+ cls, hass: HomeAssistant, config: ConfigType
+ ) -> ConfigType:
+ """Validate config."""
+ return cast(ConfigType, _PHASE_CHANGED_TRIGGER_SCHEMA(config))
+
+ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
+ """Initialize the trigger."""
+ super().__init__(hass, config)
+ options = config.options or {}
+ self._phase: str = options[CONF_PHASE]
+
+ @override
+ async def async_attach_runner(
+ self,
+ run_action: TriggerActionRunner,
+ did_not_trigger: TriggerNotTriggeredReporter | None = None,
+ ) -> CALLBACK_TYPE:
+ """Attach the trigger to an action runner."""
+ last_phase = moon_phase()
+
+ @callback
+ def check_phase(_now: datetime) -> None:
+ nonlocal last_phase
+ current_phase = moon_phase()
+ if current_phase == last_phase:
+ return
+ previous_phase = last_phase
+ last_phase = current_phase
+ if self._phase in (PHASE_ANY, current_phase):
+ run_action(
+ {"phase": current_phase, "previous_phase": previous_phase},
+ "moon phase changed",
+ )
+
+ # The binned phase can only change when the local date rolls over.
+ return async_track_time_change(
+ self._hass, check_phase, hour=0, minute=0, second=0
+ )
+
+
+TRIGGERS: dict[str, type[Trigger]] = {
+ "phase_changed": MoonPhaseChangedTrigger,
+}
+
+
+async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
+ """Return the triggers for the moon."""
+ return TRIGGERS
diff --git a/homeassistant/components/moon/triggers.yaml b/homeassistant/components/moon/triggers.yaml
new file mode 100644
index 000000000000..7a6457d452e3
--- /dev/null
+++ b/homeassistant/components/moon/triggers.yaml
@@ -0,0 +1,18 @@
+phase_changed:
+ fields:
+ phase:
+ required: true
+ default: any
+ selector:
+ select:
+ translation_key: phase
+ options:
+ - any
+ - new_moon
+ - waxing_crescent
+ - first_quarter
+ - waxing_gibbous
+ - full_moon
+ - waning_gibbous
+ - last_quarter
+ - waning_crescent
diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py
index b67949b3c01d..f40d2d5fc04a 100644
--- a/homeassistant/components/mqtt/binary_sensor.py
+++ b/homeassistant/components/mqtt/binary_sensor.py
@@ -133,7 +133,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity):
self._expiration_trigger()
self._expiration_trigger = None
self._expired = False
- await MqttEntity.async_will_remove_from_hass(self)
+ await super().async_will_remove_from_hass()
@staticmethod
@override
diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py
index 0efba71bbf72..8cf181e7699d 100644
--- a/homeassistant/components/mqtt/device_tracker.py
+++ b/homeassistant/components/mqtt/device_tracker.py
@@ -7,16 +7,18 @@ from typing import TYPE_CHECKING, Any, override
import voluptuous as vol
from homeassistant.components import device_tracker
-from homeassistant.components.device_tracker import SourceType, TrackerEntity
+from homeassistant.components.device_tracker import (
+ SourceType,
+ TrackerEntity,
+ TrackerEntityStateAttribute,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_GPS_ACCURACY,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_NAME,
CONF_VALUE_TEMPLATE,
STATE_HOME,
STATE_NOT_HOME,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -162,16 +164,18 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
) -> None:
"""Extract the location from the extra state attributes."""
if (
- ATTR_LATITUDE in extra_state_attributes
- or ATTR_LONGITUDE in extra_state_attributes
+ EntityStateAttribute.LATITUDE in extra_state_attributes
+ or EntityStateAttribute.LONGITUDE in extra_state_attributes
):
latitude: float | None
longitude: float | None
gps_accuracy: float
if isinstance(
- latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float)
+ latitude := extra_state_attributes.get(EntityStateAttribute.LATITUDE),
+ (int, float),
) and isinstance(
- longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float)
+ longitude := extra_state_attributes.get(EntityStateAttribute.LONGITUDE),
+ (int, float),
):
self._attr_latitude = latitude
self._attr_longitude = longitude
@@ -187,9 +191,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
extra_state_attributes,
)
- if ATTR_GPS_ACCURACY in extra_state_attributes:
+ if TrackerEntityStateAttribute.GPS_ACCURACY in extra_state_attributes:
if isinstance(
- gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY],
+ gps_accuracy := extra_state_attributes[
+ TrackerEntityStateAttribute.GPS_ACCURACY
+ ],
(int, float),
):
self._attr_location_accuracy = gps_accuracy
@@ -210,5 +216,10 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
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}
+ if attribute
+ not in {
+ TrackerEntityStateAttribute.GPS_ACCURACY,
+ EntityStateAttribute.LATITUDE,
+ EntityStateAttribute.LONGITUDE,
+ }
}
diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py
index 68d4b2fb9c7c..5ab4861201f4 100644
--- a/homeassistant/components/mqtt/diagnostics.py
+++ b/homeassistant/components/mqtt/diagnostics.py
@@ -5,12 +5,7 @@ from typing import Any
from homeassistant.components import device_tracker
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
- CONF_PASSWORD,
- CONF_USERNAME,
-)
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EntityStateAttribute
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
@@ -18,7 +13,10 @@ from homeassistant.helpers.device_registry import DeviceEntry
from . import debug_info, is_connected
REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME}
-REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE}
+REDACT_STATE_DEVICE_TRACKER = {
+ EntityStateAttribute.LATITUDE,
+ EntityStateAttribute.LONGITUDE,
+}
async def async_get_config_entry_diagnostics(
diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py
index f50790616a07..65edaf103317 100644
--- a/homeassistant/components/mqtt/entity.py
+++ b/homeassistant/components/mqtt/entity.py
@@ -557,6 +557,7 @@ class MqttAttributesMixin(Entity):
self._attributes_sub_state = async_unsubscribe_topics(
self.hass, self._attributes_sub_state
)
+ await super().async_will_remove_from_hass()
@callback
def _attributes_message_received(self, msg: ReceiveMessage) -> None:
@@ -708,6 +709,7 @@ class MqttAvailabilityMixin(Entity):
self._availability_sub_state = async_unsubscribe_topics(
self.hass, self._availability_sub_state
)
+ await super().async_will_remove_from_hass()
@property
@override
@@ -1253,6 +1255,7 @@ class MqttDiscoveryUpdateMixin(Entity):
async def async_will_remove_from_hass(self) -> None:
"""Stop listening to signal and cleanup discovery data."""
self._cleanup_discovery_on_remove()
+ await super().async_will_remove_from_hass()
def _cleanup_discovery_on_remove(self) -> None:
"""Stop listening to signal and cleanup discovery data."""
@@ -1575,9 +1578,7 @@ class MqttEntity(
self._sub_state = subscription.async_unsubscribe_topics(
self.hass, self._sub_state
)
- await MqttAttributesMixin.async_will_remove_from_hass(self)
- await MqttAvailabilityMixin.async_will_remove_from_hass(self)
- await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self)
+ await super().async_will_remove_from_hass()
debug_info.remove_entity_data(self.hass, self.entity_id)
async def async_publish_with_config(
diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py
index f9958db74253..80766f89499b 100644
--- a/homeassistant/components/mqtt/light/schema_template.py
+++ b/homeassistant/components/mqtt/light/schema_template.py
@@ -19,6 +19,7 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
LightEntityFeature,
+ LightEntityStateAttribute,
filter_supported_color_modes,
)
from homeassistant.const import (
@@ -361,17 +362,21 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
last_state = await self.async_get_last_state()
if self._optimistic and last_state:
self._attr_is_on = last_state.state == STATE_ON
- if last_state.attributes.get(ATTR_BRIGHTNESS):
- self._attr_brightness = last_state.attributes.get(ATTR_BRIGHTNESS)
- if last_state.attributes.get(ATTR_HS_COLOR):
- self._attr_hs_color = last_state.attributes.get(ATTR_HS_COLOR)
+ if brightness := last_state.attributes.get(
+ LightEntityStateAttribute.BRIGHTNESS
+ ):
+ self._attr_brightness = brightness
+ if hs_color := last_state.attributes.get(
+ LightEntityStateAttribute.HS_COLOR
+ ):
+ self._attr_hs_color = hs_color
self._update_color_mode()
- if last_state.attributes.get(ATTR_COLOR_TEMP_KELVIN):
- self._attr_color_temp_kelvin = last_state.attributes.get(
- ATTR_COLOR_TEMP_KELVIN
- )
- if last_state.attributes.get(ATTR_EFFECT):
- self._attr_effect = last_state.attributes.get(ATTR_EFFECT)
+ if color_temp_kelvin := last_state.attributes.get(
+ LightEntityStateAttribute.COLOR_TEMP_KELVIN
+ ):
+ self._attr_color_temp_kelvin = color_temp_kelvin
+ if effect := last_state.attributes.get(LightEntityStateAttribute.EFFECT):
+ self._attr_effect = effect
@override
async def async_turn_on(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index 8c6879fa534f..bab9483b3767 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -246,7 +246,7 @@ class MqttSensor(MqttEntity, RestoreSensor):
self._expiration_trigger()
self._expiration_trigger = None
self._expired = False
- await MqttEntity.async_will_remove_from_hass(self)
+ await super().async_will_remove_from_hass()
@staticmethod
@override
diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py
index a4c05b8843bb..30604aa4e3a2 100644
--- a/homeassistant/components/mqtt/vacuum.py
+++ b/homeassistant/components/mqtt/vacuum.py
@@ -329,7 +329,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
self.add_subscription(
CONF_STATE_TOPIC,
self._state_message_received,
- {"_attr_battery_level", "_attr_fan_speed", "_attr_activity"},
+ {"_attr_fan_speed", "_attr_activity"},
)
@override
diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py
index f11d73a6af63..17f714a45ec2 100644
--- a/homeassistant/components/music_assistant/__init__.py
+++ b/homeassistant/components/music_assistant/__init__.py
@@ -248,9 +248,7 @@ async def async_setup_entry( # noqa: C901
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
- )
+ dev_reg.async_remove_device(device.id)
return True
diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py
index 9ee4117b1e61..12d2fe496885 100644
--- a/homeassistant/components/music_assistant/helpers.py
+++ b/homeassistant/components/music_assistant/helpers.py
@@ -44,3 +44,21 @@ def get_music_assistant_client(
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError("Entry not loaded")
return entry.runtime_data.mass
+
+
+async def async_resolve_mass_username(
+ hass: HomeAssistant, user_id: str, available_usernames: list[str]
+) -> str | None:
+ """Resolve the Music Assistant username for the Home Assistant user."""
+ if (user := await hass.auth.async_get_user(user_id)) is None:
+ return None
+ for cred in user.credentials:
+ if cred.auth_provider_type == "homeassistant":
+ username: str = cred.data["username"]
+ break
+ else:
+ return None
+ username = username.strip().lower()
+ if username in available_usernames:
+ return username
+ return None
diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json
index 121273348a5b..9baa8ea1735f 100644
--- a/homeassistant/components/music_assistant/manifest.json
+++ b/homeassistant/components/music_assistant/manifest.json
@@ -10,6 +10,6 @@
"iot_class": "local_push",
"loggers": ["music_assistant"],
"quality_scale": "bronze",
- "requirements": ["music-assistant-client==1.3.6"],
+ "requirements": ["music-assistant-client==1.4.3"],
"zeroconf": ["_mass._tcp.local."]
}
diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py
index cdf843333004..74d1db426191 100644
--- a/homeassistant/components/music_assistant/media_player.py
+++ b/homeassistant/components/music_assistant/media_player.py
@@ -61,7 +61,7 @@ from .const import (
DOMAIN,
)
from .entity import MusicAssistantEntity
-from .helpers import catch_musicassistant_error
+from .helpers import async_resolve_mass_username, catch_musicassistant_error
from .media_browser import async_browse_media, async_search_media
from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
@@ -460,22 +460,28 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
username: str | None = None,
) -> None:
"""Send the play_media command to the media player."""
- # verify username availability
- if username is not None:
- users = await self.mass.auth.list_users()
+ # An explicit username is validated strictly; when omitted we default to
+ # the Home Assistant user that made the call (best-effort, never raises).
+ user_id = self._context.user_id if self._context is not None else None
+ if username is not None or user_id is not None:
available_usernames = [
user.username
- for user in users
+ for user in await self.mass.auth.list_users()
if user.enabled and user.role != UserRole.GUEST
]
- if username not in available_usernames:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="invalid_username",
- translation_placeholders={
- "username": username,
- "available_usernames": ", ".join(available_usernames),
- },
+ if username is not None:
+ if username not in available_usernames:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_username",
+ translation_placeholders={
+ "username": username,
+ "available_usernames": ", ".join(available_usernames),
+ },
+ )
+ elif user_id is not None:
+ username = await async_resolve_mass_username(
+ self.hass, user_id, available_usernames
)
media_uris: list[str] = []
diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json
index dbb65667e6d0..722e272741d3 100644
--- a/homeassistant/components/music_assistant/strings.json
+++ b/homeassistant/components/music_assistant/strings.json
@@ -426,7 +426,7 @@
"name": "Enable radio mode"
},
"username": {
- "description": "Music Assistant username used for the play media request. This affects the playlog entry and will also take provider filters of a media item into account, if they are configured.",
+ "description": "Music Assistant username used for the play media request. This affects the playlog entry and will also take provider filters of a media item into account, if they are configured. Defaults to the Home Assistant user that made the request, when the username matches a Music Assistant user.",
"name": "Username"
}
},
diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py
index 699c621a09fc..0cc545cf2711 100644
--- a/homeassistant/components/neato/vacuum.py
+++ b/homeassistant/components/neato/vacuum.py
@@ -63,8 +63,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity):
"""Representation of a Neato Connected Vacuum."""
_attr_supported_features = (
- VacuumEntityFeature.BATTERY
- | VacuumEntityFeature.PAUSE
+ VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.STOP
| VacuumEntityFeature.START
@@ -172,8 +171,6 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity):
self._attr_activity = VacuumActivity.ERROR
self._status_state = ERRORS.get(self._state["error"])
- self._attr_battery_level = self._state["details"]["charge"]
-
if self._mapdata is None or not self._mapdata.get(self._robot_serial, {}).get(
"maps", []
):
diff --git a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py
index 7061bdc83f60..6d6b2a7c1e98 100644
--- a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py
+++ b/homeassistant/components/nederlandse_spoorwegen/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.device_registry import DeviceInfo
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -109,6 +109,7 @@ class NSBinarySensor(CoordinatorEntity[NSDataUpdateCoordinator], BinarySensorEnt
name=coordinator.name,
manufacturer=INTEGRATION_TITLE,
model=ROUTE_MODEL,
+ entry_type=DeviceEntryType.SERVICE,
)
@property
diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py
index c88ef824aa11..7eef35544195 100644
--- a/homeassistant/components/nederlandse_spoorwegen/sensor.py
+++ b/homeassistant/components/nederlandse_spoorwegen/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 DeviceInfo
+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
@@ -202,6 +202,7 @@ class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
name=coordinator.name,
manufacturer=INTEGRATION_TITLE,
model=ROUTE_MODEL,
+ entry_type=DeviceEntryType.SERVICE,
)
@property
diff --git a/homeassistant/components/neopool/__init__.py b/homeassistant/components/neopool/__init__.py
new file mode 100644
index 000000000000..166f04f02d20
--- /dev/null
+++ b/homeassistant/components/neopool/__init__.py
@@ -0,0 +1,28 @@
+"""NeoPool integration for Home Assistant."""
+
+from neopool_modbus import NeoPoolModbusClient
+
+from homeassistant.core import HomeAssistant
+
+from .const import PLATFORMS
+from .coordinator import NeoPoolConfigEntry, NeoPoolCoordinator
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: NeoPoolConfigEntry) -> bool:
+ """Set up the NeoPool integration from a config entry."""
+ client = NeoPoolModbusClient(entry.data)
+ coordinator = NeoPoolCoordinator(hass, client, 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: NeoPoolConfigEntry) -> bool:
+ """Unload a NeoPool config entry."""
+ unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ if unload_ok:
+ await entry.runtime_data.client.close()
+ return unload_ok
diff --git a/homeassistant/components/neopool/config_flow.py b/homeassistant/components/neopool/config_flow.py
new file mode 100644
index 000000000000..13a4c547f150
--- /dev/null
+++ b/homeassistant/components/neopool/config_flow.py
@@ -0,0 +1,75 @@
+"""Config flow for the NeoPool integration."""
+
+from typing import Any, override
+
+from neopool_modbus import async_probe_serial
+from neopool_modbus.exceptions import (
+ NeoPoolConnectionError,
+ NeoPoolModbusError,
+ NeoPoolTimeoutError,
+)
+from neopool_modbus.registers import DEFAULT_MODBUS_FRAMER
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_PORT
+
+from .const import CURRENT_VERSION, DEFAULT_PORT, DEFAULT_UNIT_ID, DOMAIN
+
+
+async def _async_probe(user_input: dict[str, Any]) -> tuple[str | None, str | None]:
+ """Probe a device using user-supplied connection parameters."""
+ try:
+ serial = await async_probe_serial(
+ user_input[CONF_HOST],
+ port=user_input[CONF_PORT],
+ unit_id=user_input["unit_id"],
+ framer=user_input["modbus_framer"],
+ )
+ except NeoPoolConnectionError, NeoPoolTimeoutError:
+ return None, "cannot_connect"
+ except NeoPoolModbusError:
+ return None, "cannot_read_modbus"
+ return serial, None
+
+
+class NeoPoolConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for NeoPool."""
+
+ VERSION = CURRENT_VERSION
+
+ @override
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step of the configuration flow."""
+ data_schema = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int),
+ vol.Optional("unit_id", default=DEFAULT_UNIT_ID): vol.Coerce(int),
+ vol.Optional(
+ "modbus_framer",
+ default=DEFAULT_MODBUS_FRAMER,
+ ): vol.In(("tcp", "rtu")),
+ }
+ )
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ serial, error_key = await _async_probe(user_input)
+ if error_key:
+ errors[CONF_HOST] = error_key
+ else:
+ assert serial is not None
+ await self.async_set_unique_id(serial)
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=user_input[CONF_HOST], data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=data_schema,
+ errors=errors,
+ )
diff --git a/homeassistant/components/neopool/const.py b/homeassistant/components/neopool/const.py
new file mode 100644
index 000000000000..0307581f2da2
--- /dev/null
+++ b/homeassistant/components/neopool/const.py
@@ -0,0 +1,14 @@
+"""Constants for the NeoPool integration."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "neopool"
+NAME = "NeoPool"
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+DEFAULT_SCAN_INTERVAL = 20 # in seconds
+DEFAULT_PORT = 502
+DEFAULT_UNIT_ID = 1
+
+CURRENT_VERSION = 6
diff --git a/homeassistant/components/neopool/coordinator.py b/homeassistant/components/neopool/coordinator.py
new file mode 100644
index 000000000000..01b3d62dcf6c
--- /dev/null
+++ b/homeassistant/components/neopool/coordinator.py
@@ -0,0 +1,100 @@
+"""Data update coordinator for the NeoPool integration."""
+
+from datetime import timedelta
+import logging
+from typing import Any, override
+
+from neopool_modbus import NeoPoolModbusClient
+from neopool_modbus.exceptions import NeoPoolError
+from neopool_modbus.registers import MAX_RELAY_GPIO, find_corrupted_gpio_registers
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+type NeoPoolConfigEntry = ConfigEntry["NeoPoolCoordinator"]
+
+
+class NeoPoolCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+ """Coordinator for NeoPool platform."""
+
+ client: NeoPoolModbusClient
+ config_entry: NeoPoolConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ client: NeoPoolModbusClient,
+ entry: NeoPoolConfigEntry,
+ ) -> None:
+ """Initialise the NeoPool data update coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=f"{DOMAIN} coordinator",
+ update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
+ config_entry=entry,
+ )
+ self.client = client
+ self._corrupted_gpio_state: frozenset[tuple[str, int]] | None = None
+
+ def _check_gpio_registers(self, data: dict[str, Any]) -> None:
+ """Validate GPIO register values and (re-)raise or clear the repair issue."""
+ corrupted = find_corrupted_gpio_registers(data)
+ corrupted_state = frozenset((key, value) for key, _, value in corrupted)
+
+ if corrupted_state == self._corrupted_gpio_state:
+ return
+
+ for key, label, value in corrupted:
+ _LOGGER.error(
+ "Corrupted GPIO register %s (%s): value %d (0x%04X) is outside "
+ "valid range 0-%d. The pool controller may malfunction",
+ key,
+ label,
+ value,
+ value & 0xFFFF,
+ MAX_RELAY_GPIO,
+ )
+
+ self._corrupted_gpio_state = corrupted_state
+
+ if corrupted:
+ details = "\n".join(
+ f"- **{label}** (`{key}`): value **{value}** (expected 0-{MAX_RELAY_GPIO})"
+ for key, label, value in corrupted
+ )
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ "corrupted_gpio",
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="corrupted_gpio",
+ translation_placeholders={"details": details},
+ )
+ else:
+ # Clear a previously raised repair issue once the device is healthy.
+ ir.async_delete_issue(self.hass, DOMAIN, "corrupted_gpio")
+
+ @override
+ async def _async_update_data(self) -> dict[str, Any]:
+ """Fetch the latest data from the pool controller."""
+ try:
+ data = await self.client.async_read_all()
+ except (NeoPoolError, OSError, TimeoutError) as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="modbus_communication_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
+
+ self._check_gpio_registers(data)
+
+ return data
diff --git a/homeassistant/components/neopool/entity.py b/homeassistant/components/neopool/entity.py
new file mode 100644
index 000000000000..3c3fdd3e7a1c
--- /dev/null
+++ b/homeassistant/components/neopool/entity.py
@@ -0,0 +1,36 @@
+"""Base entity class for the NeoPool integration."""
+
+from typing import override
+
+from neopool_modbus.decoders import get_machine_name, parse_version
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, NAME
+from .coordinator import NeoPoolCoordinator
+
+
+class NeoPoolEntity(CoordinatorEntity[NeoPoolCoordinator]):
+ """Base class for NeoPool entities."""
+
+ _attr_has_entity_name = True
+
+ @property
+ @override
+ def device_info(self) -> DeviceInfo:
+ """Return device information for the entity."""
+ data = self.coordinator.data or {}
+ unique_id = self.coordinator.config_entry.unique_id
+ assert unique_id is not None
+ machine_type = (get_machine_name(data) or "").strip()
+ model_prefix = "NeoPool Compatible: " if machine_type else "NeoPool Compatible"
+
+ return DeviceInfo(
+ identifiers={(DOMAIN, unique_id)},
+ name=NAME,
+ model=f"{model_prefix}{machine_type}".strip(),
+ manufacturer="Hayward (Sugar Valley)",
+ sw_version=f"v{parse_version(data.get('MBF_POWER_MODULE_VERSION'))} (v{parse_version(data.get('MBF_PAR_VERSION'))})",
+ serial_number=unique_id,
+ )
diff --git a/homeassistant/components/neopool/icons.json b/homeassistant/components/neopool/icons.json
new file mode 100644
index 000000000000..90d561743cf1
--- /dev/null
+++ b/homeassistant/components/neopool/icons.json
@@ -0,0 +1,65 @@
+{
+ "entity": {
+ "sensor": {
+ "filt_mode": {
+ "default": "mdi:water-sync",
+ "state": {
+ "auto": "mdi:water-boiler-auto",
+ "backwash": "mdi:water-boiler-off",
+ "heating": "mdi:water-boiler-alert",
+ "intelligent": "mdi:water-boiler-auto",
+ "manual": "mdi:water-boiler-alert",
+ "smart": "mdi:water-boiler-auto"
+ }
+ },
+ "filtration_speed": {
+ "default": "mdi:fan"
+ },
+ "filtvalve_remaining": {
+ "default": "mdi:timer-sand"
+ },
+ "hidro_current": {
+ "default": "mdi:air-humidifier-off",
+ "range": {
+ "10": "mdi:air-humidifier"
+ }
+ },
+ "hidro_polarity": {
+ "default": "mdi:plus-minus-variant"
+ },
+ "intelligent_intervals": {
+ "default": "mdi:counter"
+ },
+ "intelligent_tt_next_interval": {
+ "default": "mdi:timeline-clock-outline"
+ },
+ "ion_current": {
+ "default": "mdi:atom"
+ },
+ "ion_polarity": {
+ "default": "mdi:plus-minus-variant"
+ },
+ "measure_cl": {
+ "default": "mdi:shaker-outline"
+ },
+ "measure_rx": {
+ "default": "mdi:gradient-vertical"
+ },
+ "ph_pump_status": {
+ "default": "mdi:pump"
+ },
+ "ph_status_alarm": {
+ "default": "mdi:ph",
+ "state": {
+ "ok": "mdi:check-circle-outline",
+ "ph_high": "mdi:alert",
+ "ph_low": "mdi:alert",
+ "ph_over": "mdi:alert",
+ "ph_under": "mdi:alert",
+ "pump_stopped": "mdi:alert",
+ "tank_level": "mdi:alert"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/neopool/manifest.json b/homeassistant/components/neopool/manifest.json
new file mode 100644
index 000000000000..9c0658e5031b
--- /dev/null
+++ b/homeassistant/components/neopool/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "neopool",
+ "name": "NeoPool",
+ "codeowners": ["@svasek"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/neopool",
+ "integration_type": "hub",
+ "iot_class": "local_polling",
+ "loggers": ["neopool_modbus"],
+ "quality_scale": "silver",
+ "requirements": ["neopool-modbus==3.6.0"]
+}
diff --git a/homeassistant/components/neopool/quality_scale.yaml b/homeassistant/components/neopool/quality_scale.yaml
new file mode 100644
index 000000000000..1893a01bff50
--- /dev/null
+++ b/homeassistant/components/neopool/quality_scale.yaml
@@ -0,0 +1,96 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration does not register any service 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 does not register any service actions.
+ docs-conditions:
+ status: exempt
+ comment: The integration does not provide any conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: The integration does not provide any triggers.
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities use the coordinator pattern and do not subscribe to
+ integration-specific 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 does not register any service actions.
+ 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:
+ status: exempt
+ comment: Modbus TCP has no authentication mechanism.
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery:
+ status: exempt
+ comment: |
+ Modbus TCP gateways have no standard discovery protocol
+ (no zeroconf, SSDP, or DHCP signal that uniquely identifies
+ a NeoPool controller behind the gateway).
+ discovery-update-info:
+ status: exempt
+ comment: See discovery exemption above.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ One config entry maps to one physical NeoPool controller; multiple
+ controllers are supported via separate config entries. The single
+ device per entry is created during initial setup and cannot change.
+ 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: |
+ One config entry maps to one physical device; the device is not
+ removed during runtime, so there are no stale devices to clean up.
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: Integration uses Modbus TCP, not HTTP, so no aiohttp session is involved.
+ strict-typing: done
diff --git a/homeassistant/components/neopool/sensor.py b/homeassistant/components/neopool/sensor.py
new file mode 100644
index 000000000000..830628fb73e3
--- /dev/null
+++ b/homeassistant/components/neopool/sensor.py
@@ -0,0 +1,380 @@
+"""Sensor platform for the NeoPool integration."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, override
+
+from neopool_modbus.capabilities import (
+ has_filtvalve,
+ has_heating_relay,
+ has_variable_speed_pump,
+ is_chlorine_module_present,
+ is_conductivity_module_present,
+ is_hydrolysis_present,
+ is_ionization_present,
+ is_ph_module_present,
+ is_redox_module_present,
+ is_temperature_active,
+)
+from neopool_modbus.decoders import (
+ FILTRATION_MODE_LABELS,
+ FILTRATION_SPEED_STATE_LABELS,
+ HIDRO_POLARITY_LABELS,
+ ION_POLARITY_LABELS,
+ PH_STATUS_ALARM_LABELS,
+ calculate_next_interval_time,
+ decode_hidro_polarity,
+ decode_ion_polarity,
+ decode_ph_alarm,
+ decode_ph_pump_status,
+ is_hydrolysis_in_percent,
+ ph_pump_options,
+)
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import (
+ EntityCategory,
+ UnitOfElectricPotential,
+ UnitOfRatio,
+ UnitOfTemperature,
+ UnitOfTime,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import NeoPoolConfigEntry
+from .coordinator import NeoPoolCoordinator
+from .entity import NeoPoolEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class NeoPoolSensorEntityDescription(SensorEntityDescription):
+ """Describes a NeoPool sensor entity."""
+
+ supported_fn: Callable[[dict[str, Any]], bool] | None = None
+ value_fn: Callable[[dict[str, Any]], Any] | None = None
+ options_fn: Callable[[dict[str, Any]], list[str]] | None = None
+ unit_fn: Callable[[dict[str, Any]], str | None] | None = None
+ precision_fn: Callable[[dict[str, Any]], int | None] | None = None
+
+
+SENSOR_DESCRIPTIONS: dict[str, NeoPoolSensorEntityDescription] = {
+ "MBF_ION_CURRENT": NeoPoolSensorEntityDescription(
+ key="MBF_ION_CURRENT",
+ translation_key="ion_current",
+ native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=is_ionization_present,
+ ),
+ "MBF_HIDRO_CURRENT": NeoPoolSensorEntityDescription(
+ key="MBF_HIDRO_CURRENT",
+ translation_key="hidro_current",
+ native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=0,
+ supported_fn=is_hydrolysis_present,
+ unit_fn=lambda data: (
+ UnitOfRatio.PERCENTAGE if is_hydrolysis_in_percent(data) else "g/h"
+ ),
+ precision_fn=lambda data: 0 if is_hydrolysis_in_percent(data) else 1,
+ ),
+ "MBF_MEASURE_PH": NeoPoolSensorEntityDescription(
+ key="MBF_MEASURE_PH",
+ device_class=SensorDeviceClass.PH,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=is_ph_module_present,
+ ),
+ "MBF_MEASURE_RX": NeoPoolSensorEntityDescription(
+ key="MBF_MEASURE_RX",
+ translation_key="measure_rx",
+ native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=is_redox_module_present,
+ ),
+ "MBF_MEASURE_CL": NeoPoolSensorEntityDescription(
+ key="MBF_MEASURE_CL",
+ translation_key="measure_cl",
+ native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=is_chlorine_module_present,
+ ),
+ "MBF_MEASURE_CONDUCTIVITY": NeoPoolSensorEntityDescription(
+ key="MBF_MEASURE_CONDUCTIVITY",
+ translation_key="measure_conductivity",
+ native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=0,
+ supported_fn=is_conductivity_module_present,
+ ),
+ "MBF_MEASURE_TEMPERATURE": NeoPoolSensorEntityDescription(
+ key="MBF_MEASURE_TEMPERATURE",
+ translation_key="measure_temperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=is_temperature_active,
+ ),
+ "MBF_HIDRO_VOLTAGE": NeoPoolSensorEntityDescription(
+ key="MBF_HIDRO_VOLTAGE",
+ translation_key="hidro_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ suggested_display_precision=1,
+ entity_registry_enabled_default=False,
+ supported_fn=is_hydrolysis_present,
+ ),
+ "MBF_PAR_FILT_MODE": NeoPoolSensorEntityDescription(
+ key="MBF_PAR_FILT_MODE",
+ translation_key="filt_mode",
+ device_class=SensorDeviceClass.ENUM,
+ options=list(FILTRATION_MODE_LABELS.values()),
+ value_fn=lambda data: data.get("filtration_mode"),
+ ),
+ "MBF_PH_STATUS_ALARM": NeoPoolSensorEntityDescription(
+ key="MBF_PH_STATUS_ALARM",
+ translation_key="ph_status_alarm",
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=list(PH_STATUS_ALARM_LABELS.values()),
+ value_fn=decode_ph_alarm,
+ supported_fn=is_ph_module_present,
+ ),
+ "HIDRO_POLARITY": NeoPoolSensorEntityDescription(
+ key="HIDRO_POLARITY",
+ translation_key="hidro_polarity",
+ device_class=SensorDeviceClass.ENUM,
+ options=list(HIDRO_POLARITY_LABELS),
+ value_fn=decode_hidro_polarity,
+ supported_fn=is_hydrolysis_present,
+ ),
+ "ION_POLARITY": NeoPoolSensorEntityDescription(
+ key="ION_POLARITY",
+ translation_key="ion_polarity",
+ device_class=SensorDeviceClass.ENUM,
+ options=list(ION_POLARITY_LABELS),
+ value_fn=decode_ion_polarity,
+ supported_fn=is_ionization_present,
+ ),
+ "PH_PUMP_STATUS": NeoPoolSensorEntityDescription(
+ key="PH_PUMP_STATUS",
+ translation_key="ph_pump_status",
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options_fn=ph_pump_options,
+ value_fn=decode_ph_pump_status,
+ supported_fn=is_ph_module_present,
+ ),
+ "FILTRATION_SPEED": NeoPoolSensorEntityDescription(
+ key="FILTRATION_SPEED",
+ translation_key="filtration_speed",
+ device_class=SensorDeviceClass.ENUM,
+ options=list(FILTRATION_SPEED_STATE_LABELS),
+ value_fn=lambda data: data.get("filtration_speed_state"),
+ supported_fn=has_variable_speed_pump,
+ ),
+ "MBF_PAR_INTELLIGENT_INTERVALS": NeoPoolSensorEntityDescription(
+ key="MBF_PAR_INTELLIGENT_INTERVALS",
+ translation_key="intelligent_intervals",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ supported_fn=lambda data: (
+ has_heating_relay(data) and is_temperature_active(data)
+ ),
+ ),
+ "MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL": NeoPoolSensorEntityDescription(
+ key="MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL",
+ translation_key="intelligent_tt_next_interval",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: calculate_next_interval_time(
+ data.get("MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL")
+ ),
+ supported_fn=lambda data: (
+ has_heating_relay(data) and is_temperature_active(data)
+ ),
+ ),
+ "MBF_PAR_FILTVALVE_REMAINING": NeoPoolSensorEntityDescription(
+ key="MBF_PAR_FILTVALVE_REMAINING",
+ translation_key="filtvalve_remaining",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=0,
+ supported_fn=has_filtvalve,
+ ),
+ "CELL_RUNTIME_TOTAL": NeoPoolSensorEntityDescription(
+ key="CELL_RUNTIME_TOTAL",
+ translation_key="cell_runtime_total",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=0,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ supported_fn=is_hydrolysis_present,
+ ),
+ "CELL_RUNTIME_PART": NeoPoolSensorEntityDescription(
+ key="CELL_RUNTIME_PART",
+ translation_key="cell_runtime_part",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=0,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ supported_fn=is_hydrolysis_present,
+ ),
+ "CELL_RUNTIME_POLA": NeoPoolSensorEntityDescription(
+ key="CELL_RUNTIME_POLA",
+ translation_key="cell_runtime_pola",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=0,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ supported_fn=is_hydrolysis_present,
+ ),
+ "CELL_RUNTIME_POLB": NeoPoolSensorEntityDescription(
+ key="CELL_RUNTIME_POLB",
+ translation_key="cell_runtime_polb",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=0,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ supported_fn=is_hydrolysis_present,
+ ),
+ "CELL_RUNTIME_POL_CHANGES": NeoPoolSensorEntityDescription(
+ key="CELL_RUNTIME_POL_CHANGES",
+ translation_key="cell_runtime_pol_changes",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=0,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ supported_fn=is_hydrolysis_present,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: NeoPoolConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up NeoPool sensors from a config entry."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ NeoPoolSensor(coordinator, key, desc)
+ for key, desc in SENSOR_DESCRIPTIONS.items()
+ if desc.supported_fn is None or desc.supported_fn(coordinator.data)
+ )
+
+
+_PRODUCTION_KEYS_REQUIRING_FILTRATION = frozenset(
+ {
+ "MBF_HIDRO_CURRENT",
+ "MBF_HIDRO_VOLTAGE",
+ "MBF_ION_CURRENT",
+ }
+)
+
+_MEASURE_KEYS_REQUIRING_FILTRATION = frozenset(
+ {
+ "MBF_MEASURE_TEMPERATURE",
+ "MBF_MEASURE_PH",
+ "MBF_MEASURE_RX",
+ "MBF_MEASURE_CL",
+ "MBF_MEASURE_CONDUCTIVITY",
+ }
+)
+
+
+class NeoPoolSensor(NeoPoolEntity, SensorEntity):
+ """Representation of a NeoPool sensor."""
+
+ entity_description: NeoPoolSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: NeoPoolCoordinator,
+ key: str,
+ description: NeoPoolSensorEntityDescription,
+ ) -> None:
+ """Initialize the NeoPool sensor entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._key = key
+ self._attr_unique_id = (
+ f"{self.coordinator.config_entry.unique_id}_{key.lower()}"
+ )
+
+ @property
+ @override
+ def suggested_display_precision(self) -> int | None:
+ """Return the suggested display precision for the sensor value."""
+ if (precision_fn := self.entity_description.precision_fn) is not None:
+ return precision_fn(self.coordinator.data)
+ return super().suggested_display_precision
+
+ @property
+ @override
+ def native_unit_of_measurement(self) -> str | None:
+ """Return the unit of measurement for the sensor value."""
+ if (unit_fn := self.entity_description.unit_fn) is not None:
+ return unit_fn(self.coordinator.data)
+ return super().native_unit_of_measurement
+
+ def _filtration_off(self) -> bool:
+ """Return True when the filtration pump is off."""
+ return self.coordinator.data.get("Filtration Pump") is False
+
+ def _is_measurement_suppressed(self) -> bool:
+ """Return True if a measurement sensor should report None."""
+ if self._key not in _MEASURE_KEYS_REQUIRING_FILTRATION:
+ return False
+ return self._filtration_off()
+
+ def _is_production_suppressed(self) -> bool:
+ """Return True if a production sensor should report 0."""
+ if self._key not in _PRODUCTION_KEYS_REQUIRING_FILTRATION:
+ return False
+ return self._filtration_off()
+
+ @property
+ @override
+ def native_value(self) -> float | int | str | datetime | None:
+ """Return the actual sensor value from coordinator data."""
+ if self._is_measurement_suppressed():
+ return None
+ if self._is_production_suppressed():
+ return 0
+ if (value_fn := self.entity_description.value_fn) is not None:
+ value: float | int | str | datetime | None = value_fn(self.coordinator.data)
+ return value
+ return self.coordinator.data.get(self._key)
+
+ @property
+ @override
+ def options(self) -> list[str] | None:
+ """Return the list of options for the sensor."""
+ if (options_fn := self.entity_description.options_fn) is not None:
+ return options_fn(self.coordinator.data)
+ return super().options
diff --git a/homeassistant/components/neopool/strings.json b/homeassistant/components/neopool/strings.json
new file mode 100644
index 000000000000..4f3bea3e33d8
--- /dev/null
+++ b/homeassistant/components/neopool/strings.json
@@ -0,0 +1,150 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "cannot_read_modbus": "Connected, but cannot read from the Modbus device. Check unit ID and framer settings."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "modbus_framer": "Modbus framer",
+ "port": "[%key:common::config_flow::data::port%]",
+ "unit_id": "Unit ID"
+ },
+ "data_description": {
+ "host": "Enter the IP address of the Modbus TCP gateway connected to your pool controller.",
+ "modbus_framer": "Wire protocol used by the gateway. Select TCP for MBAP framing (default for Ethernet-native gateways). Select RTU for RTU framing tunnelled through a TCP socket, used by passthrough serial-to-TCP bridges like ESPHome's stream_server.",
+ "port": "Standard Modbus TCP port (default: 502). Change only if your gateway uses a non-standard port.",
+ "unit_id": "Modbus device address (1-247). Identifies the specific device on a shared bus. Most direct-connected devices use 1. Gateways may route requests to different addresses."
+ },
+ "description": "Configure the connection to your NeoPool controller.",
+ "title": "NeoPool Connection"
+ }
+ }
+ },
+ "entity": {
+ "sensor": {
+ "cell_runtime_part": {
+ "name": "Cell runtime since reset"
+ },
+ "cell_runtime_pol_changes": {
+ "name": "Cell polarity changes"
+ },
+ "cell_runtime_pola": {
+ "name": "Cell runtime in polarity 1"
+ },
+ "cell_runtime_polb": {
+ "name": "Cell runtime in polarity 2"
+ },
+ "cell_runtime_total": {
+ "name": "Cell runtime total"
+ },
+ "filt_mode": {
+ "name": "Filtration mode",
+ "state": {
+ "auto": "Automatic",
+ "backwash": "Backwash",
+ "heating": "Heating",
+ "intelligent": "Intelligent",
+ "manual": "[%key:common::state::manual%]",
+ "smart": "Smart"
+ }
+ },
+ "filtration_speed": {
+ "name": "Current filtration speed",
+ "state": {
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "mid": "Medium",
+ "off": "[%key:common::state::off%]"
+ }
+ },
+ "filtvalve_remaining": {
+ "name": "Backwash time remaining"
+ },
+ "hidro_current": {
+ "name": "Hydrolysis intensity"
+ },
+ "hidro_polarity": {
+ "name": "Hydrolysis polarity",
+ "state": {
+ "dead_time": "Dead time",
+ "no_flow": "No flow",
+ "off": "[%key:common::state::off%]",
+ "pol1": "Polarity 1",
+ "pol2": "Polarity 2"
+ }
+ },
+ "hidro_voltage": {
+ "name": "Hydrolysis voltage"
+ },
+ "intelligent_intervals": {
+ "name": "Intelligent mode intervals"
+ },
+ "intelligent_tt_next_interval": {
+ "name": "Intelligent mode next interval start"
+ },
+ "ion_current": {
+ "name": "Ionization level"
+ },
+ "ion_polarity": {
+ "name": "Ionizer polarity",
+ "state": {
+ "dead_time": "Dead time",
+ "off": "[%key:common::state::off%]",
+ "pol1": "Polarity 1",
+ "pol2": "Polarity 2"
+ }
+ },
+ "measure_cl": {
+ "name": "Salt level"
+ },
+ "measure_conductivity": {
+ "name": "Conductivity level"
+ },
+ "measure_rx": {
+ "name": "Redox potential"
+ },
+ "measure_temperature": {
+ "name": "Water temperature"
+ },
+ "ph_pump_status": {
+ "name": "pH pump status",
+ "state": {
+ "acid": "Acid pump",
+ "base": "Base pump",
+ "both": "Both pumps",
+ "idle": "[%key:common::state::idle%]",
+ "off": "[%key:common::state::off%]"
+ }
+ },
+ "ph_status_alarm": {
+ "name": "pH alarm",
+ "state": {
+ "ok": "OK",
+ "ph_high": "pH too high",
+ "ph_low": "pH too low",
+ "ph_over": "pH higher than the set point",
+ "ph_under": "pH lower than the set point",
+ "pump_stopped": "Pump stopped (exceeded working time)",
+ "tank_level": "Tank level alarm"
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "modbus_communication_error": {
+ "message": "An error occurred while communicating with the NeoPool controller: {error}"
+ }
+ },
+ "issues": {
+ "corrupted_gpio": {
+ "description": "The following GPIO register(s) on your pool controller contain invalid values:\n\n{details}\n\nThis typically happens when the Modbus gateway framing mode does not match the integration's framer setting. The affected function(s) will not work correctly until the register(s) are restored to valid values.\n\nSee the integration documentation for repair instructions.",
+ "title": "Corrupted GPIO register(s) detected"
+ }
+ }
+}
diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py
index fec919b72372..174b8686a4aa 100644
--- a/homeassistant/components/nest/__init__.py
+++ b/homeassistant/components/nest/__init__.py
@@ -236,10 +236,7 @@ class SignalUpdateCallback:
if device_id in devices:
continue
_LOGGER.info("Removing stale device entry '%s'", device_id)
- device_registry.async_update_device(
- device_id=device_entry.id,
- remove_config_entry_id=self._config_entry.entry_id,
- )
+ device_registry.async_remove_device(device_entry.id)
async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool:
diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py
index 8289b2bf0539..183732c5ea1c 100644
--- a/homeassistant/components/netatmo/__init__.py
+++ b/homeassistant/components/netatmo/__init__.py
@@ -29,17 +29,8 @@ from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
from . import api
-from .const import (
- DATA_CAMERAS,
- DATA_DEVICE_IDS,
- DATA_EVENTS,
- DATA_HOMES,
- DATA_PERSONS,
- DATA_SCHEDULES,
- DOMAIN,
- PLATFORMS,
-)
-from .data_handler import NetatmoConfigEntry, NetatmoDataHandler
+from .const import DOMAIN, PLATFORMS
+from .coordinator import NetatmoConfigEntry, NetatmoDataHandler
from .services import async_setup_services
from .webhook import async_register_webhook, async_unregister_webhook
@@ -52,17 +43,6 @@ MAX_WEBHOOK_RETRIES = 3
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Netatmo component."""
- # Uses legacy hass.data[DOMAIN] pattern
- # pylint: disable-next=home-assistant-use-runtime-data
- hass.data[DOMAIN] = {
- DATA_PERSONS: {},
- DATA_DEVICE_IDS: {},
- DATA_SCHEDULES: {},
- DATA_HOMES: {},
- DATA_EVENTS: {},
- DATA_CAMERAS: {},
- }
-
async_setup_services(hass)
return True
diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py
index d0dc4348690a..e8bb4e15a18a 100644
--- a/homeassistant/components/netatmo/binary_sensor.py
+++ b/homeassistant/components/netatmo/binary_sensor.py
@@ -37,11 +37,13 @@ from .const import (
NETATMO_CREATE_OPENING_BINARY_SENSOR,
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
)
-from .data_handler import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
+from .coordinator import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity, NetatmoWeatherModuleEntity
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_OPENING_SENSOR_KEY = "opening_sensor"
OPENING_STATUS_TO_BINARY_SENSOR_STATE: Final[dict[str, bool | None]] = {
diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py
index dd0895a28157..a2f41356e1ce 100644
--- a/homeassistant/components/netatmo/button.py
+++ b/homeassistant/components/netatmo/button.py
@@ -11,12 +11,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON
-from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
-from .entity import NetatmoModuleEntity
+from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
+from .entity import NetatmoReachabilityEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -36,7 +38,7 @@ async def async_setup_entry(
)
-class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity):
+class NetatmoCoverPreferredPositionButton(NetatmoReachabilityEntity, ButtonEntity):
"""Representation of a Netatmo cover preferred position button device."""
_attr_configuration_url = CONF_URL_CONTROL
@@ -67,7 +69,7 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity):
@override
def async_update_callback(self) -> None:
"""Update the entity's state."""
- # No state to update for button
+ self.async_write_ha_state()
@override
async def async_press(self) -> None:
diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py
index b1dc17f4f5d9..9ae69c6a2435 100644
--- a/homeassistant/components/netatmo/camera.py
+++ b/homeassistant/components/netatmo/camera.py
@@ -1,5 +1,4 @@
"""Support for the Netatmo cameras."""
-# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
from typing import Any, cast, override
@@ -24,8 +23,6 @@ from .const import (
CAMERA_LIGHT_MODES,
CAMERA_TRIGGERS,
CONF_URL_SECURITY,
- DATA_CAMERAS,
- DATA_EVENTS,
DOMAIN,
EVENT_TYPE_CONNECTION,
EVENT_TYPE_DISCONNECTION,
@@ -40,12 +37,14 @@ from .const import (
SERVICE_SET_PERSONS_HOME,
WEBHOOK_PUSH_TYPE,
)
-from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
+from .coordinator import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_QUALITY = "high"
@@ -137,7 +136,7 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
)
)
- self.hass.data[DOMAIN][DATA_CAMERAS][self.device.entity_id] = self.device.name
+ self.data_handler.cameras[self.device.entity_id] = self.device.name
@callback
def handle_event(self, event: dict) -> None:
@@ -281,10 +280,12 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
self._attr_is_streaming = self.device.monitoring
self._attr_motion_detection_enabled = self.device.monitoring
- self.hass.data[DOMAIN][DATA_EVENTS][self.device.entity_id] = (
- self.process_events(self.device.events)
+ self.data_handler.events[self.device.entity_id] = self.process_events(
+ self.device.events
)
+ self.async_write_ha_state()
+
def process_events(self, event_list: list[NaEvent]) -> dict:
"""Add meta data to events."""
events = {}
diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py
index 936be63126f2..0b5fea41517a 100644
--- a/homeassistant/components/netatmo/climate.py
+++ b/homeassistant/components/netatmo/climate.py
@@ -1,5 +1,4 @@
"""Support for Netatmo Smart thermostats."""
-# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
from typing import Any, cast, override
@@ -39,7 +38,6 @@ from .const import (
ATTR_SELECTED_SCHEDULE_ID,
ATTR_TARGET_TEMPERATURE,
ATTR_TIME_PERIOD,
- DATA_SCHEDULES,
DOMAIN,
EVENT_TYPE_CANCEL_SET_POINT,
EVENT_TYPE_SCHEDULE,
@@ -52,12 +50,14 @@ from .const import (
SERVICE_SET_TEMPERATURE_WITH_END_DATETIME,
SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD,
)
-from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom
+from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom
from .entity import NetatmoRoomEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
PRESET_FROST_GUARD = "frost_guard"
PRESET_SCHEDULE = "schedule"
PRESET_MANUAL = "manual"
@@ -254,7 +254,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
if data["event_type"] == EVENT_TYPE_SCHEDULE:
# handle schedule change
if "schedule_id" in data:
- selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][
+ selected_schedule = self.data_handler.schedules[
self.home.entity_id
].get(data["schedule_id"])
self._selected_schedule = getattr(
@@ -290,6 +290,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
elif self._attr_preset_mode in [PRESET_SCHEDULE, PRESET_HOME]:
self.async_update_callback()
self.data_handler.async_force_update(self._signal_name)
+ return
self.async_write_ha_state()
return
@@ -325,7 +326,6 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
self._attr_preset_mode = PRESET_MAP_NETATMO[PRESET_SCHEDULE]
self.async_update_callback()
- self.async_write_ha_state()
return
@property
@@ -414,15 +414,16 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
@override
def available(self) -> bool:
"""If the device hasn't been able to connect, mark as unavailable."""
- return bool(self._connected)
+ return super().available and bool(self._connected)
@callback
@override
def async_update_callback(self) -> None:
"""Update the entity's state."""
if not self.device.reachable:
- if self.available:
+ if self._connected:
self._connected = False
+ self.async_write_ha_state()
return
self._connected = True
@@ -458,12 +459,12 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
self._boilerstatus = module.boiler_status
break
+ self.async_write_ha_state()
+
async def _async_service_set_schedule(self, **kwargs: Any) -> None:
schedule_name = kwargs.get(ATTR_SCHEDULE_NAME)
schedule_id = None
- for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][
- self.home.entity_id
- ].items():
+ for sid, schedule in self.data_handler.schedules[self.home.entity_id].items():
if schedule.name == schedule_name:
schedule_id = sid
break
diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py
index 0883cf89d2ac..3693bb76105c 100644
--- a/homeassistant/components/netatmo/config_flow.py
+++ b/homeassistant/components/netatmo/config_flow.py
@@ -7,7 +7,7 @@ import uuid
import voluptuous as vol
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
+from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_SHOW_ON_MAP, CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
@@ -24,7 +24,7 @@ from .const import (
CONF_WEATHER_AREAS,
DOMAIN,
)
-from .data_handler import NetatmoConfigEntry
+from .coordinator import NetatmoConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -62,10 +62,6 @@ class NetatmoFlowHandler(
async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult:
"""Handle a flow start."""
await self.async_set_unique_id(DOMAIN)
-
- if self.source != SOURCE_REAUTH and self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
return await super().async_step_user(user_input)
async def async_step_reauth(
diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py
index 41bfc09dc905..96bdb16b9201 100644
--- a/homeassistant/components/netatmo/const.py
+++ b/homeassistant/components/netatmo/const.py
@@ -71,13 +71,6 @@ CONF_WEATHER_AREAS = "weather_areas"
OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize"
OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token"
-DATA_CAMERAS = "cameras"
-DATA_DEVICE_IDS = "netatmo_device_ids"
-DATA_EVENTS = "netatmo_events"
-DATA_HOMES = "netatmo_homes"
-DATA_PERSONS = "netatmo_persons"
-DATA_SCHEDULES = "netatmo_schedules"
-
NETATMO_EVENT = "netatmo_event"
DEFAULT_DISCOVERY = True
diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/coordinator.py
similarity index 91%
rename from homeassistant/components/netatmo/data_handler.py
rename to homeassistant/components/netatmo/coordinator.py
index 8d63a2016f3d..34eb67f3a7ce 100644
--- a/homeassistant/components/netatmo/data_handler.py
+++ b/homeassistant/components/netatmo/coordinator.py
@@ -1,5 +1,4 @@
"""The Netatmo data handler."""
-# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from collections import deque
from dataclasses import dataclass
@@ -15,6 +14,7 @@ from pyatmo.modules.device_types import (
DeviceCategory as NetatmoDeviceCategory,
DeviceType as NetatmoDeviceType,
)
+from pyatmo.schedule import Schedule
from homeassistant.components import cloud
from homeassistant.config_entries import ConfigEntry
@@ -27,8 +27,6 @@ from homeassistant.helpers.event import async_track_time_interval
from .const import (
CAMERA_CONNECTION_WEBHOOKS,
- DATA_PERSONS,
- DATA_SCHEDULES,
DOMAIN,
MANUFACTURER,
NETATMO_CREATE_BUTTON,
@@ -91,6 +89,12 @@ SCAN_INTERVAL = 60
type NetatmoConfigEntry = ConfigEntry[NetatmoDataHandler]
+def async_get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry | None:
+ """Return the single loaded Netatmo config entry, if any."""
+ entries = hass.config_entries.async_loaded_entries(DOMAIN)
+ return entries[0] if entries else None
+
+
@dataclass
class NetatmoDevice:
"""Netatmo device class."""
@@ -131,6 +135,7 @@ class NetatmoPublisher:
subscriptions: set[CALLBACK_TYPE | None]
method: str
kwargs: dict
+ available: bool = True
class NetatmoDataHandler:
@@ -160,6 +165,11 @@ class NetatmoDataHandler:
self._rate_limit = DEV_LIMIT
self.poll_start = time()
self.poll_count = 0
+ self.persons: dict[str, dict[str, str | None]] = {}
+ self.schedules: dict[str, dict[str, Schedule]] = {}
+ self.device_ids: dict[str, str] = {}
+ self.cameras: dict[str, str] = {}
+ self.events: dict[str, dict] = {}
async def async_setup(self) -> None:
"""Set up the Netatmo data handler."""
@@ -245,19 +255,29 @@ class NetatmoDataHandler:
**self.publisher[signal_name].kwargs
)
- except (pyatmo.NoDeviceError, pyatmo.ApiError) as err:
+ except (
+ pyatmo.NoDeviceError,
+ pyatmo.ApiError,
+ TimeoutError,
+ aiohttp.ClientConnectorError,
+ ) as err:
_LOGGER.debug(err)
has_error = True
- except (TimeoutError, aiohttp.ClientConnectorError) as err:
- _LOGGER.debug(err)
- return True
+ self.publisher[signal_name].available = not has_error
+ self._notify_subscribers(signal_name)
+ return has_error
+ def _notify_subscribers(self, signal_name: str) -> None:
+ """Notify all subscribers of a publisher to update their state."""
for update_callback in self.publisher[signal_name].subscriptions:
if update_callback:
update_callback()
- return has_error
+ def is_signal_available(self, signal_name: str) -> bool:
+ """Return whether the last fetch for a publisher succeeded."""
+ publisher = self.publisher.get(signal_name)
+ return publisher is None or publisher.available
async def subscribe(
self,
@@ -330,7 +350,7 @@ class NetatmoDataHandler:
self.setup_rooms(home, signal_home)
self.setup_modules(home, signal_home)
- self.hass.data[DOMAIN][DATA_PERSONS][home.entity_id] = {
+ self.persons[home.entity_id] = {
person.entity_id: person.pseudo for person in home.persons.values()
}
@@ -459,7 +479,7 @@ class NetatmoDataHandler:
if NetatmoDeviceCategory.climate in [
next(iter(x)) for x in [room.features for room in home.rooms.values()] if x
]:
- self.hass.data[DOMAIN][DATA_SCHEDULES][home.entity_id] = self.account.homes[
+ self.schedules[home.entity_id] = self.account.homes[
home.entity_id
].schedules
diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py
index ba244724f736..82c97c0c45c6 100644
--- a/homeassistant/components/netatmo/cover.py
+++ b/homeassistant/components/netatmo/cover.py
@@ -16,12 +16,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER
-from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
-from .entity import NetatmoModuleEntity
+from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
+from .entity import NetatmoReachabilityEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -41,7 +43,7 @@ async def async_setup_entry(
)
-class NetatmoCover(NetatmoModuleEntity, CoverEntity):
+class NetatmoCover(NetatmoReachabilityEntity, CoverEntity):
"""Representation of a Netatmo cover device."""
_attr_supported_features = (
@@ -103,5 +105,7 @@ class NetatmoCover(NetatmoModuleEntity, CoverEntity):
@override
def async_update_callback(self) -> None:
"""Update the entity's state."""
- self._attr_is_closed = self.device.current_position == 0
- self._attr_current_cover_position = self.device.current_position
+ if self.device.reachable is not False:
+ self._attr_is_closed = self.device.current_position == 0
+ self._attr_current_cover_position = self.device.current_position
+ self.async_write_ha_state()
diff --git a/homeassistant/components/netatmo/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py
index a3b4f032714d..08bbd0a1ff89 100644
--- a/homeassistant/components/netatmo/diagnostics.py
+++ b/homeassistant/components/netatmo/diagnostics.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
-from .data_handler import ACCOUNT, NetatmoConfigEntry
+from .coordinator import ACCOUNT, NetatmoConfigEntry
TO_REDACT = {
"access_token",
diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py
index 2b2b5ea52b8c..ae301cb06fc9 100644
--- a/homeassistant/components/netatmo/entity.py
+++ b/homeassistant/components/netatmo/entity.py
@@ -7,7 +7,7 @@ from pyatmo import DeviceType, Home, Module, Room
from pyatmo.modules.base_class import NetatmoBase, Place
from pyatmo.modules.device_types import DEVICE_DESCRIPTION_MAP
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -16,12 +16,11 @@ from homeassistant.helpers.entity import Entity
from .const import (
CONF_URL_ENERGY,
CONF_URL_WEATHER,
- DATA_DEVICE_IDS,
DEFAULT_ATTRIBUTION,
DOMAIN,
SIGNAL_NAME,
)
-from .data_handler import PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom
+from .coordinator import PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom
class NetatmoBaseEntity(Entity):
@@ -36,6 +35,15 @@ class NetatmoBaseEntity(Entity):
self._publishers: list[dict[str, Any]] = []
self._attr_extra_state_attributes = {}
+ @property
+ @override
+ def available(self) -> bool:
+ """Return True if the underlying data publishers are reachable."""
+ return super().available and all(
+ self.data_handler.is_signal_available(publisher[SIGNAL_NAME])
+ for publisher in self._publishers
+ )
+
@override
async def async_added_to_hass(self) -> None:
"""Entity created."""
@@ -141,9 +149,7 @@ class NetatmoRoomEntity(NetatmoDeviceEntity):
if device := registry.async_get_device(
identifiers={(DOMAIN, self.device.entity_id)}
):
- # Uses legacy hass.data[DOMAIN] pattern
- # pylint: disable-next=home-assistant-use-runtime-data
- self.hass.data[DOMAIN][DATA_DEVICE_IDS][self.device.entity_id] = device.id
+ self.data_handler.device_ids[self.device.entity_id] = device.id
@property
@override
@@ -177,6 +183,16 @@ class NetatmoModuleEntity(NetatmoDeviceEntity):
return self.device.device_type
+class NetatmoReachabilityEntity(NetatmoModuleEntity):
+ """Module entity that is unavailable when its device is unreachable."""
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return True unless the device explicitly reports as unreachable."""
+ return super().available and self.device.reachable is not False
+
+
class NetatmoWeatherModuleEntity(NetatmoModuleEntity):
"""Netatmo weather module entity base class."""
@@ -201,8 +217,8 @@ class NetatmoWeatherModuleEntity(NetatmoModuleEntity):
if hasattr(place, "location") and place.location is not None:
self._attr_extra_state_attributes.update(
{
- ATTR_LATITUDE: place.location.latitude,
- ATTR_LONGITUDE: place.location.longitude,
+ EntityStateAttribute.LATITUDE: place.location.latitude,
+ EntityStateAttribute.LONGITUDE: place.location.longitude,
}
)
diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py
index 3dacf5b3fe5f..0e4a4eb828a3 100644
--- a/homeassistant/components/netatmo/fan.py
+++ b/homeassistant/components/netatmo/fan.py
@@ -11,12 +11,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN
-from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
-from .entity import NetatmoModuleEntity
+from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
+from .entity import NetatmoReachabilityEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_PERCENTAGE: Final = 50
PRESET_MAPPING = {"slow": 1, "fast": 2}
@@ -41,7 +43,7 @@ async def async_setup_entry(
)
-class NetatmoFan(NetatmoModuleEntity, FanEntity):
+class NetatmoFan(NetatmoReachabilityEntity, FanEntity):
"""Representation of a Netatmo fan."""
_attr_preset_modes = ["slow", "fast"]
@@ -76,7 +78,9 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity):
@override
def async_update_callback(self) -> None:
"""Update the entity's state."""
- if self.device.fan_speed is None:
- self._attr_preset_mode = None
- return
- self._attr_preset_mode = PRESETS.get(self.device.fan_speed)
+ if self.device.reachable is not False:
+ if self.device.fan_speed is None:
+ self._attr_preset_mode = None
+ else:
+ self._attr_preset_mode = PRESETS.get(self.device.fan_speed)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py
index 3bbc6f320c9e..2e84133e1707 100644
--- a/homeassistant/components/netatmo/light.py
+++ b/homeassistant/components/netatmo/light.py
@@ -19,11 +19,13 @@ from .const import (
NETATMO_CREATE_CAMERA_LIGHT,
NETATMO_CREATE_LIGHT,
)
-from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
-from .entity import NetatmoModuleEntity
+from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
+from .entity import NetatmoModuleEntity, NetatmoReachabilityEntity
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -122,7 +124,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity):
@override
def available(self) -> bool:
"""If the webhook is not established, mark as unavailable."""
- return bool(self.data_handler.webhook)
+ return super().available and bool(self.data_handler.webhook)
@override
async def async_turn_on(self, **kwargs: Any) -> None:
@@ -141,9 +143,10 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity):
def async_update_callback(self) -> None:
"""Update the entity's state."""
self._attr_is_on = bool(self.device.floodlight == "on")
+ self.async_write_ha_state()
-class NetatmoLight(NetatmoModuleEntity, LightEntity):
+class NetatmoLight(NetatmoReachabilityEntity, LightEntity):
"""Representation of a dimmable light by Legrand/BTicino."""
_attr_name = None
@@ -198,10 +201,12 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity):
@override
def async_update_callback(self) -> None:
"""Update the entity's state."""
- self._attr_is_on = self.device.on is True
+ if self.device.reachable is not False:
+ self._attr_is_on = self.device.on is True
- if (brightness := self.device.brightness) is not None:
- # Netatmo uses a range of [0, 100] to control brightness
- self._attr_brightness = round(brightness * 2.55)
- else:
- self._attr_brightness = None
+ if (brightness := self.device.brightness) is not None:
+ # Netatmo uses a range of [0, 100] to control brightness
+ self._attr_brightness = round(brightness * 2.55)
+ else:
+ self._attr_brightness = None
+ self.async_write_ha_state()
diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json
index 6d6aea230f18..51b827f1edb9 100644
--- a/homeassistant/components/netatmo/manifest.json
+++ b/homeassistant/components/netatmo/manifest.json
@@ -12,5 +12,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
- "requirements": ["pyatmo==9.4.0"]
+ "quality_scale": "bronze",
+ "requirements": ["pyatmo==9.4.0"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py
index 653b82e5acc2..2006e43a94a2 100644
--- a/homeassistant/components/netatmo/media_source.py
+++ b/homeassistant/components/netatmo/media_source.py
@@ -1,5 +1,4 @@
"""Netatmo Media Source Implementation."""
-# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import datetime as dt
import logging
@@ -15,9 +14,11 @@ from homeassistant.components.media_source import (
PlayMedia,
Unresolvable,
)
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
-from .const import DATA_CAMERAS, DATA_EVENTS, DOMAIN, MANUFACTURER
+from .const import DOMAIN, MANUFACTURER
+from .coordinator import NetatmoConfigEntry, NetatmoDataHandler, async_get_loaded_entry
_LOGGER = logging.getLogger(__name__)
MIME_TYPE = "application/x-mpegURL"
@@ -29,7 +30,7 @@ class IncompatibleMediaSource(MediaSourceError):
async def async_get_media_source(hass: HomeAssistant) -> NetatmoSource:
"""Set up Netatmo media source."""
- return NetatmoSource(hass)
+ return NetatmoSource(hass, async_get_loaded_entry(hass))
class NetatmoSource(MediaSource):
@@ -37,11 +38,24 @@ class NetatmoSource(MediaSource):
name: str = MANUFACTURER
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, entry: NetatmoConfigEntry | None) -> None:
"""Initialize Netatmo source."""
super().__init__(DOMAIN)
self.hass = hass
- self.events = self.hass.data[DOMAIN][DATA_EVENTS]
+ self.entry = entry
+
+ @property
+ def _data_handler(self) -> NetatmoDataHandler | None:
+ """Return the data handler of the config entry, if it is loaded."""
+ if self.entry is None or self.entry.state is not ConfigEntryState.LOADED:
+ return None
+ return self.entry.runtime_data
+
+ @property
+ def events(self) -> dict[str, dict]:
+ """Return the camera events."""
+ data_handler = self._data_handler
+ return data_handler.events if data_handler else {}
@override
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
@@ -85,7 +99,12 @@ class NetatmoSource(MediaSource):
)
title = f"{created} - {message}"
else:
- title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER)
+ data_handler = self._data_handler
+ title = (
+ data_handler.cameras.get(camera_id, MANUFACTURER)
+ if data_handler
+ else MANUFACTURER
+ )
thumbnail = None
if event_id:
diff --git a/homeassistant/components/netatmo/quality_scale.yaml b/homeassistant/components/netatmo/quality_scale.yaml
new file mode 100644
index 000000000000..d69785e45207
--- /dev/null
+++ b/homeassistant/components/netatmo/quality_scale.yaml
@@ -0,0 +1,70 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-conditions:
+ status: exempt
+ comment: Integration does not register custom conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: Integration does not register custom triggers.
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure:
+ status: exempt
+ comment: >-
+ The integration is set up via an OAuth2 flow that has no user-entered
+ connection parameters to validate before configuration.
+ 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: done
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices: todo
+ diagnostics: 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: done
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py
index 7596146b4749..3527e8ae7dff 100644
--- a/homeassistant/components/netatmo/select.py
+++ b/homeassistant/components/netatmo/select.py
@@ -1,5 +1,4 @@
"""Support for the Netatmo climate schedule selector."""
-# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
from typing import override
@@ -12,17 +11,18 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_URL_ENERGY,
- DATA_SCHEDULES,
DOMAIN,
EVENT_TYPE_SCHEDULE,
MANUFACTURER,
NETATMO_CREATE_SELECT,
)
-from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome
+from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome
from .entity import NetatmoBaseEntity
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -100,18 +100,16 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity):
return
if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data:
- if schedule := self.hass.data[DOMAIN][DATA_SCHEDULES][
- self.home.entity_id
- ].get(data["schedule_id"]):
+ if schedule := self.data_handler.schedules[self.home.entity_id].get(
+ data["schedule_id"]
+ ):
self._attr_current_option = schedule.name
self.async_write_ha_state()
@override
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
- for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][
- self.home.entity_id
- ].items():
+ for sid, schedule in self.data_handler.schedules[self.home.entity_id].items():
if schedule.name != option:
continue
_LOGGER.debug(
@@ -130,9 +128,8 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity):
schedule = self.home.get_selected_schedule()
assert schedule
self._attr_current_option = schedule.name
- self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id] = (
- self.home.schedules
- )
+ self.data_handler.schedules[self.home.entity_id] = self.home.schedules
self._attr_options = [
schedule.name for schedule in self.home.schedules.values() if schedule.name
]
+ self.async_write_ha_state()
diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py
index 24bc7e149bd2..043b7cfb9842 100644
--- a/homeassistant/components/netatmo/sensor.py
+++ b/homeassistant/components/netatmo/sensor.py
@@ -17,10 +17,9 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
DEGREE,
EntityCategory,
+ EntityStateAttribute,
UnitOfPower,
UnitOfPrecipitationDepth,
UnitOfPressure,
@@ -53,7 +52,7 @@ from .const import (
NETATMO_CREATE_WEATHER_SENSOR,
SIGNAL_NAME,
)
-from .data_handler import (
+from .coordinator import (
HOME,
PUBLIC,
NetatmoConfigEntry,
@@ -63,6 +62,7 @@ from .data_handler import (
)
from .entity import (
NetatmoBaseEntity,
+ NetatmoDeviceEntity,
NetatmoModuleEntity,
NetatmoRoomEntity,
NetatmoWeatherModuleEntity,
@@ -71,6 +71,9 @@ from .helper import NetatmoArea
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
+
DIRECTION_OPTIONS = [
"n",
"ne",
@@ -629,7 +632,25 @@ async def async_setup_entry(
await add_public_entities(False)
-class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity):
+class NetatmoLegacyReachableSensor(NetatmoDeviceEntity, SensorEntity):
+ """Sensor mixin that goes unavailable, keeping its last value, when unreachable."""
+
+ @callback
+ def _async_set_unavailable_if_unreachable(self) -> bool:
+ """Set the entity unavailable and write state when the device is unreachable.
+
+ Returns True when the device is unreachable so callers return early.
+ """
+ device = cast("pyatmo.Module | pyatmo.Room", self.device)
+ if device.reachable:
+ return False
+ if self.available:
+ self._attr_available = False
+ self.async_write_ha_state()
+ return True
+
+
+class NetatmoBaseSensor(NetatmoModuleEntity, NetatmoLegacyReachableSensor):
"""Implementation of a Netatmo sensor."""
entity_description: NetatmoSensorEntityDescription
@@ -664,16 +685,11 @@ class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity):
"""Update the entity's state (the legacy way)."""
# Keep the last known value for these legacy sensors when the device is
# unreachable to preserve the historical behavior expected by existing entities.
- if not self.device.reachable:
- if self.available:
- self._attr_available = False
- return
-
- if (state := getattr(self.device, self.entity_description.key)) is None:
+ if self._async_set_unavailable_if_unreachable():
return
self._attr_available = True
- self._attr_native_value = state
+ self._attr_native_value = getattr(self.device, self.entity_description.key)
self.async_write_ha_state()
@@ -698,7 +714,7 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, NetatmoBaseSensor):
@override
def available(self) -> bool:
"""Return True if entity is available."""
- return (
+ return super().available and (
self.device.reachable
or getattr(
self.device,
@@ -790,9 +806,7 @@ class NetatmoClimateBatterySensor(NetatmoLegacySensor):
@override
def async_update_callback(self) -> None:
"""Update the entity's state."""
- if not self.device.reachable:
- if self.available:
- self._attr_available = False
+ if self._async_set_unavailable_if_unreachable():
return
self._attr_available = True
@@ -859,7 +873,7 @@ class NetatmoSensor(NetatmoBaseSensor):
self.async_write_ha_state()
-class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity):
+class NetatmoRoomSensor(NetatmoRoomEntity, NetatmoLegacyReachableSensor):
"""Implementation of a Netatmo room sensor."""
entity_description: NetatmoSensorEntityDescription
@@ -891,10 +905,11 @@ class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity):
@override
def async_update_callback(self) -> None:
"""Update the entity's state."""
- if (state := getattr(self.device, self.entity_description.key)) is None:
+ if self._async_set_unavailable_if_unreachable():
return
- self._attr_native_value = state
+ self._attr_available = True
+ self._attr_native_value = getattr(self.device, self.entity_description.key)
self.async_write_ha_state()
@@ -936,8 +951,8 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity):
self._attr_extra_state_attributes.update(
{
- ATTR_LATITUDE: (area.lat_ne + area.lat_sw) / 2,
- ATTR_LONGITUDE: (area.lon_ne + area.lon_sw) / 2,
+ EntityStateAttribute.LATITUDE: (area.lat_ne + area.lat_sw) / 2,
+ EntityStateAttribute.LONGITUDE: (area.lon_ne + area.lon_sw) / 2,
}
)
self._attr_device_info = DeviceInfo(
@@ -974,6 +989,17 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity):
self._signal_name = f"{PUBLIC}-{area.uuid}"
self._mode = area.mode
self._show_on_map = area.show_on_map
+ self._publishers = [
+ {
+ "name": PUBLIC,
+ "lat_ne": area.lat_ne,
+ "lon_ne": area.lon_ne,
+ "lat_sw": area.lat_sw,
+ "lon_sw": area.lon_sw,
+ "area_name": area.area_name,
+ SIGNAL_NAME: self._signal_name,
+ }
+ ]
await self.data_handler.subscribe(
PUBLIC,
self._signal_name,
@@ -999,6 +1025,7 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity):
)
self._attr_available = False
+ self.async_write_ha_state()
return
if values := [x for x in data.values() if x is not None]:
diff --git a/homeassistant/components/netatmo/services.py b/homeassistant/components/netatmo/services.py
index 750e88baf308..1265b70fa665 100644
--- a/homeassistant/components/netatmo/services.py
+++ b/homeassistant/components/netatmo/services.py
@@ -1,12 +1,11 @@
"""Services for the Netatmo integration."""
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN
-from .data_handler import NetatmoConfigEntry
+from .coordinator import NetatmoConfigEntry, async_get_loaded_entry
from .webhook import async_register_webhook, async_unregister_webhook
SERVICE_REGISTER_WEBHOOK = "register_webhook"
@@ -15,10 +14,7 @@ SERVICE_UNREGISTER_WEBHOOK = "unregister_webhook"
def _get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry:
"""Return the loaded config entry or raise if unavailable."""
- entry: NetatmoConfigEntry | None = (
- hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN)
- )
- if entry is None or entry.state is not ConfigEntryState.LOADED:
+ if (entry := async_get_loaded_entry(hass)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json
index 728381c7d33f..ad5d45da49a9 100644
--- a/homeassistant/components/netatmo/strings.json
+++ b/homeassistant/components/netatmo/strings.json
@@ -181,6 +181,15 @@
"mode": "Calculation",
"show_on_map": "Show on map"
},
+ "data_description": {
+ "area_name": "Name used to identify this weather area.",
+ "lat_ne": "Latitude of the northeast corner of the area.",
+ "lat_sw": "Latitude of the southwest corner of the area.",
+ "lon_ne": "Longitude of the northeast corner of the area.",
+ "lon_sw": "Longitude of the southwest corner of the area.",
+ "mode": "How values from multiple stations in the area are aggregated.",
+ "show_on_map": "Whether to show the sensors on the map."
+ },
"description": "Configure a public weather sensor for an area.",
"title": "Netatmo public weather sensor"
},
@@ -189,6 +198,10 @@
"new_area": "Area name",
"weather_areas": "Weather areas"
},
+ "data_description": {
+ "new_area": "Name of a new weather area to add.",
+ "weather_areas": "Configured weather areas to edit or remove."
+ },
"description": "Configure public weather sensors.",
"title": "[%key:component::netatmo::options::step::public_weather::title%]"
}
diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py
index 7bb3f83387ef..8a07e7951dcd 100644
--- a/homeassistant/components/netatmo/switch.py
+++ b/homeassistant/components/netatmo/switch.py
@@ -11,12 +11,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH
-from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
-from .entity import NetatmoModuleEntity
+from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
+from .entity import NetatmoReachabilityEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -36,7 +38,7 @@ async def async_setup_entry(
)
-class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity):
+class NetatmoSwitch(NetatmoReachabilityEntity, SwitchEntity):
"""Representation of a Netatmo switch device."""
_attr_name = None
@@ -68,7 +70,9 @@ class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity):
@override
def async_update_callback(self) -> None:
"""Update the entity's state."""
- self._attr_is_on = self.device.on
+ if self.device.reachable is not False:
+ self._attr_is_on = self.device.on
+ self.async_write_ha_state()
@override
async def async_turn_on(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py
index c34abe3c76bf..b5cd935abdcb 100644
--- a/homeassistant/components/netatmo/webhook.py
+++ b/homeassistant/components/netatmo/webhook.py
@@ -1,9 +1,8 @@
"""The Netatmo integration."""
-# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
-
import logging
import secrets
+from typing import Any
from aiohttp.web import Request
import pyatmo
@@ -31,8 +30,6 @@ from .const import (
ATTR_HOME_ID,
ATTR_IS_KNOWN,
CONF_CLOUDHOOK_URL,
- DATA_DEVICE_IDS,
- DATA_PERSONS,
DEFAULT_PERSON,
DOMAIN,
EVENT_ID_MAP,
@@ -40,7 +37,7 @@ from .const import (
WEBHOOK_DEACTIVATION,
WEBHOOK_PUSH_TYPE,
)
-from .data_handler import NetatmoConfigEntry
+from .coordinator import NetatmoConfigEntry, NetatmoDataHandler
_LOGGER = logging.getLogger(__name__)
@@ -62,19 +59,31 @@ async def async_handle_webhook(
_LOGGER.debug("Got webhook data: %s", data)
+ entry = next(
+ (
+ entry
+ for entry in hass.config_entries.async_loaded_entries(DOMAIN)
+ if entry.data.get(CONF_WEBHOOK_ID) == webhook_id
+ ),
+ None,
+ )
+ if entry is None:
+ return
+ data_handler = entry.runtime_data
+
event_type = data.get(ATTR_EVENT_TYPE)
if event_type in SUBEVENT_TYPE_MAP:
- async_send_event(hass, event_type, data)
+ async_send_event(data_handler, event_type, data)
for event_data in data.get(SUBEVENT_TYPE_MAP[event_type], []):
- async_evaluate_event(hass, event_data)
+ async_evaluate_event(data_handler, event_data)
else:
- async_evaluate_event(hass, data)
+ async_evaluate_event(data_handler, data)
-def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None:
+def async_evaluate_event(data_handler: NetatmoDataHandler, event_data: dict) -> None:
"""Evaluate events from webhook."""
event_type = event_data.get(ATTR_EVENT_TYPE, "None")
@@ -82,20 +91,23 @@ def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None:
for person in event_data.get(ATTR_PERSONS, {}):
person_event_data = dict(event_data)
person_event_data[ATTR_ID] = person.get(ATTR_ID)
- person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS][
+ person_event_data[ATTR_NAME] = data_handler.persons[
event_data[ATTR_HOME_ID]
].get(person_event_data[ATTR_ID], DEFAULT_PERSON)
person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN)
person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL)
- async_send_event(hass, event_type, person_event_data)
+ async_send_event(data_handler, event_type, person_event_data)
else:
- async_send_event(hass, event_type, event_data)
+ async_send_event(data_handler, event_type, event_data)
-def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None:
+def async_send_event(
+ data_handler: NetatmoDataHandler, event_type: str, data: dict
+) -> None:
"""Send events."""
+ hass = data_handler.hass
_LOGGER.debug("%s: %s", event_type, data)
async_dispatcher_send(
hass,
@@ -103,16 +115,14 @@ def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None:
{"type": event_type, "data": data},
)
- event_data = {
+ event_data: dict[str, Any] = {
"type": event_type,
"data": data,
}
if event_type in EVENT_ID_MAP:
data_device_id = data[EVENT_ID_MAP[event_type]]
- event_data[ATTR_DEVICE_ID] = hass.data[DOMAIN][DATA_DEVICE_IDS].get(
- data_device_id
- )
+ event_data[ATTR_DEVICE_ID] = data_handler.device_ids.get(data_device_id)
hass.bus.async_fire(
event_type=NETATMO_EVENT,
diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py
index afc32d4c5be6..2212644bce60 100644
--- a/homeassistant/components/netgear/__init__.py
+++ b/homeassistant/components/netgear/__init__.py
@@ -94,9 +94,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearConfigEntry) ->
if device_entry.via_device_id is None:
router_id = device_entry.id
continue # do not remove the router itself
- device_registry.async_update_device(
- device_entry.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(device_entry.id)
# Remove entities that are no longer tracked
entity_registry = er.async_get(hass)
entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py
index 6221de06693e..f05a571755af 100644
--- a/homeassistant/components/netgear/const.py
+++ b/homeassistant/components/netgear/const.py
@@ -50,6 +50,7 @@ PORT_5555 = 5555
# update method V2 models
MODELS_V2 = [
"Orbi",
+ "RBE",
"RBK",
"RBR",
"RBS",
diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py
index a2c6338f21cf..af4a3a2fa1cd 100644
--- a/homeassistant/components/netgear_lte/__init__.py
+++ b/homeassistant/components/netgear_lte/__init__.py
@@ -1,4 +1,5 @@
"""Support for Netgear LTE modems."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from typing import Any
diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json
index a76da88914d6..7dee6332f583 100644
--- a/homeassistant/components/network/manifest.json
+++ b/homeassistant/components/network/manifest.json
@@ -2,7 +2,6 @@
"domain": "network",
"name": "Network Configuration",
"codeowners": ["@home-assistant/core"],
- "dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/network",
"integration_type": "system",
"iot_class": "local_push",
diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py
index b21853618cef..5d0029f4448e 100644
--- a/homeassistant/components/nextbus/__init__.py
+++ b/homeassistant/components/nextbus/__init__.py
@@ -17,6 +17,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry_stop = entry.data[CONF_STOP]
coordinator_key = f"{entry_agency}-{entry_stop}"
+ # Uses legacy hass.data[DOMAIN] pattern
+ # pylint: disable-next=home-assistant-use-runtime-data
coordinator: NextBusDataUpdateCoordinator | None = hass.data.setdefault(
DOMAIN, {}
).get(
diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py
index 06cf5d662a7b..8b72fc856690 100644
--- a/homeassistant/components/nextcloud/config_flow.py
+++ b/homeassistant/components/nextcloud/config_flow.py
@@ -11,7 +11,11 @@ from nextcloudmonitor import (
)
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ ConfigFlow,
+ ConfigFlowResult,
+)
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from .const import DEFAULT_VERIFY_SSL, DOMAIN
@@ -46,8 +50,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN):
user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)
- @override
- async def async_step_user(
+ async def async_step_config(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
@@ -62,16 +65,37 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN):
except NextcloudMonitorConnectionError, NextcloudMonitorRequestError:
errors["base"] = "connection_error"
else:
+ if self.source == SOURCE_RECONFIGURE:
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(), data_updates=user_input
+ )
return self.async_create_entry(
title=user_input[CONF_URL],
data=user_input,
)
- data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, user_input)
+ data = user_input
+ if self.source == SOURCE_RECONFIGURE:
+ data = data or dict(self._get_reconfigure_entry().data)
+
+ data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, data)
return self.async_show_form(
- step_id="user", data_schema=data_schema, errors=errors
+ step_id="config", data_schema=data_schema, errors=errors
)
+ @override
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ return await self.async_step_config(user_input)
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfigure flow initialized by the user."""
+ return await self.async_step_config(user_input)
+
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json
index 373bd86b4f42..a4997e6e78e2 100644
--- a/homeassistant/components/nextcloud/strings.json
+++ b/homeassistant/components/nextcloud/strings.json
@@ -3,7 +3,8 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"connection_error_during_import": "Connection error occurred during yaml configuration import",
- "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%]"
},
"error": {
"connection_error": "[%key:common::config_flow::error::cannot_connect%]",
@@ -11,14 +12,7 @@
},
"flow_title": "Nextcloud",
"step": {
- "reauth_confirm": {
- "data": {
- "password": "[%key:common::config_flow::data::password%]",
- "username": "[%key:common::config_flow::data::username%]"
- },
- "description": "Update your login information for {url}."
- },
- "user": {
+ "config": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"url": "[%key:common::config_flow::data::url%]",
@@ -26,6 +20,13 @@
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Enter your Nextcloud information."
+ },
+ "reauth_confirm": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]",
+ "username": "[%key:common::config_flow::data::username%]"
+ },
+ "description": "Update your login information for {url}."
}
}
},
diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py
index 24feb44320b5..f06a06ffeeb2 100644
--- a/homeassistant/components/nina/__init__.py
+++ b/homeassistant/components/nina/__init__.py
@@ -4,6 +4,8 @@ from typing import Any
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
from .const import (
_LOGGER,
@@ -12,11 +14,14 @@ from .const import (
CONF_FILTER_CORONA,
CONF_FILTERS,
CONF_HEADLINE_FILTER,
+ DOMAIN,
NO_MATCH_REGEX,
)
from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator
+from .services import async_setup_services
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool:
@@ -32,6 +37,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool
return True
+async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
+ """Set up services."""
+ async_setup_services(hass)
+ return True
+
+
async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py
index d7e4a4711dd0..d5e53cbcd359 100644
--- a/homeassistant/components/nina/binary_sensor.py
+++ b/homeassistant/components/nina/binary_sensor.py
@@ -23,6 +23,17 @@ from .const import (
ATTR_WEB,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
+ SERVICE_DATA_AFFECTED_AREAS,
+ SERVICE_DATA_DESCRIPTION,
+ SERVICE_DATA_EXPIRES,
+ SERVICE_DATA_HEADLINE,
+ SERVICE_DATA_ID,
+ SERVICE_DATA_RECOMMENDED_ACTIONS,
+ SERVICE_DATA_SENDER,
+ SERVICE_DATA_SENT,
+ SERVICE_DATA_SEVERITY,
+ SERVICE_DATA_START,
+ SERVICE_DATA_WEB,
)
from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator
from .entity import NinaEntity
@@ -105,3 +116,24 @@ class NINAMessage(NinaEntity, BinarySensorEntity):
if data.expires
else "", # Deprecated, remove in 2026.11
}
+
+ def get_details(self) -> dict[str, str] | None:
+ """Return the details of the warning."""
+ if not self.is_on:
+ return None
+
+ data = self._get_warning_data()
+
+ return {
+ SERVICE_DATA_HEADLINE: data.headline,
+ SERVICE_DATA_DESCRIPTION: data.description,
+ SERVICE_DATA_SENDER: data.sender,
+ SERVICE_DATA_SEVERITY: data.severity or "Unknown",
+ SERVICE_DATA_RECOMMENDED_ACTIONS: data.recommended_actions,
+ SERVICE_DATA_AFFECTED_AREAS: data.affected_areas,
+ SERVICE_DATA_WEB: data.more_info_url,
+ SERVICE_DATA_ID: data.id,
+ SERVICE_DATA_SENT: data.sent.isoformat(),
+ SERVICE_DATA_START: data.start.isoformat() if data.start else "",
+ SERVICE_DATA_EXPIRES: data.expires.isoformat() if data.expires else "",
+ }
diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py
index 9af64322eacf..56e69dc4fc1f 100644
--- a/homeassistant/components/nina/const.py
+++ b/homeassistant/components/nina/const.py
@@ -15,6 +15,20 @@ ALL_MATCH_REGEX: str = ".*"
SEVERITY_VALUES: list[str] = ["extreme", "severe", "moderate", "minor", "unknown"]
+SERVICE_GET_DETAILS: str = "get_details"
+
+SERVICE_DATA_HEADLINE: str = "headline"
+SERVICE_DATA_DESCRIPTION: str = "description"
+SERVICE_DATA_SENDER: str = "sender"
+SERVICE_DATA_SEVERITY: str = "severity"
+SERVICE_DATA_RECOMMENDED_ACTIONS: str = "recommended_actions"
+SERVICE_DATA_AFFECTED_AREAS: str = "affected_areas"
+SERVICE_DATA_WEB: str = "web"
+SERVICE_DATA_ID: str = "id"
+SERVICE_DATA_SENT: str = "sent"
+SERVICE_DATA_START: str = "start"
+SERVICE_DATA_EXPIRES: str = "expires"
+
CONF_REGIONS: str = "regions"
CONF_MESSAGE_SLOTS: str = "slots"
CONF_FILTERS: str = "filters"
diff --git a/homeassistant/components/nina/icons.json b/homeassistant/components/nina/icons.json
new file mode 100644
index 000000000000..f0b4ac1a9115
--- /dev/null
+++ b/homeassistant/components/nina/icons.json
@@ -0,0 +1,24 @@
+{
+ "entity": {
+ "sensor": {
+ "affected_areas": {
+ "default": "mdi:map-marker-radius"
+ },
+ "headline": {
+ "default": "mdi:text-short"
+ },
+ "more_info_url": {
+ "default": "mdi:web"
+ },
+ "sender": {
+ "default": "mdi:account-tie-voice"
+ },
+ "severity": {
+ "default": "mdi:alert"
+ }
+ }
+ },
+ "services": {
+ "get_details": { "service": "mdi:download" }
+ }
+}
diff --git a/homeassistant/components/nina/quality_scale.yaml b/homeassistant/components/nina/quality_scale.yaml
index 9f0f051f6549..39e827890a00 100644
--- a/homeassistant/components/nina/quality_scale.yaml
+++ b/homeassistant/components/nina/quality_scale.yaml
@@ -1,19 +1,13 @@
rules:
# Bronze
- action-setup:
- status: exempt
- comment: |
- This integration does not provide additional actions.
+ 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 provide additional actions.
+ docs-actions: done
docs-conditions:
status: exempt
comment: This integration does not have any conditions.
@@ -35,10 +29,7 @@ rules:
unique-config-entry: done
# Silver
- action-exceptions:
- status: exempt
- comment: |
- This integration does not provide additional actions.
+ action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
@@ -78,7 +69,7 @@ rules:
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
- icon-translations: todo
+ icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
diff --git a/homeassistant/components/nina/services.py b/homeassistant/components/nina/services.py
new file mode 100644
index 000000000000..b26e9394de25
--- /dev/null
+++ b/homeassistant/components/nina/services.py
@@ -0,0 +1,21 @@
+"""Services for NINA."""
+
+from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
+from homeassistant.core import HomeAssistant, SupportsResponse, callback
+from homeassistant.helpers import service
+
+from .const import DOMAIN, SERVICE_GET_DETAILS
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register services."""
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_GET_DETAILS,
+ entity_domain=BINARY_SENSOR_DOMAIN,
+ schema=None,
+ func="get_details",
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/nina/services.yaml b/homeassistant/components/nina/services.yaml
new file mode 100644
index 000000000000..2c57ec07fc2a
--- /dev/null
+++ b/homeassistant/components/nina/services.yaml
@@ -0,0 +1,5 @@
+get_details:
+ target:
+ entity:
+ domain: binary_sensor
+ integration: nina
diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json
index 2e36e2fd61e0..ea885c6c1b00 100644
--- a/homeassistant/components/nina/strings.json
+++ b/homeassistant/components/nina/strings.json
@@ -129,5 +129,11 @@
"title": "Options"
}
}
+ },
+ "services": {
+ "get_details": {
+ "description": "Retrieves the details of a warning.",
+ "name": "Get warning details"
+ }
}
}
diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py
index ad2d7aa3705b..590783d61a4d 100644
--- a/homeassistant/components/nmap_tracker/__init__.py
+++ b/homeassistant/components/nmap_tracker/__init__.py
@@ -1,4 +1,5 @@
"""The Nmap Tracker integration."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import asyncio
from dataclasses import dataclass
diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py
index 97c489aeb14f..0d47a125e2b4 100644
--- a/homeassistant/components/nmbs/__init__.py
+++ b/homeassistant/components/nmbs/__init__.py
@@ -1,4 +1,5 @@
"""The NMBS component."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py
index 8e7cdc298eba..e38b7de4af11 100644
--- a/homeassistant/components/nmbs/sensor.py
+++ b/homeassistant/components/nmbs/sensor.py
@@ -10,10 +10,9 @@ from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_NAME,
CONF_SHOW_ON_MAP,
+ EntityStateAttribute,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
@@ -275,8 +274,8 @@ class NMBSSensor(SensorEntity):
attrs["departure_minutes"] = departure
if self._show_on_map and self.station_coordinates:
- attrs[ATTR_LATITUDE] = self.station_coordinates[0]
- attrs[ATTR_LONGITUDE] = self.station_coordinates[1]
+ attrs[EntityStateAttribute.LATITUDE] = self.station_coordinates[0]
+ attrs[EntityStateAttribute.LONGITUDE] = self.station_coordinates[1]
if self.is_via_connection and not self._excl_vias:
via = self._attrs.vias[0]
diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py
index 59f3ad1789b8..2529610da6c1 100644
--- a/homeassistant/components/nobo_hub/__init__.py
+++ b/homeassistant/components/nobo_hub/__init__.py
@@ -1,6 +1,8 @@
"""The Nobø Ecohub integration."""
-from pynobo import nobo
+import logging
+
+from pynobo import PynoboConnectionError, nobo
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -10,7 +12,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
@@ -25,6 +27,8 @@ from .const import (
NOBO_MANUFACTURER,
)
+_LOGGER = logging.getLogger(__name__)
+
PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]
type NoboHubConfigEntry = ConfigEntry[nobo]
@@ -49,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b
try:
hub = await _connect(stored_ip)
- except OSError as err:
+ except PynoboConnectionError as err:
# Stored IP may be stale - try UDP rediscovery to pick up a new
# DHCP lease (or a hub that's been moved).
discovered = await nobo.async_discover_hubs(serial=serial)
@@ -62,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b
new_ip, _ = next(iter(discovered))
try:
hub = await _connect(new_ip)
- except OSError as rediscover_err:
+ except PynoboConnectionError as rediscover_err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
@@ -80,6 +84,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close)
)
+
+ def _log_connection_state(_hub: nobo, connected: bool) -> None:
+ """Log hub connection-state transitions."""
+ if connected:
+ _LOGGER.info("Reconnected to Nobø Ecohub %s", serial)
+ else:
+ _LOGGER.info("Lost connection to Nobø Ecohub %s", serial)
+
+ hub.register_connection_callback(_log_connection_state)
+ entry.async_on_unload(
+ lambda: hub.deregister_connection_callback(_log_connection_state)
+ )
entry.runtime_data = hub
device_registry = dr.async_get(hass)
@@ -100,6 +116,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ @callback
+ def _cleanup_devices(_hub: nobo) -> None:
+ """Remove devices for zones and components no longer on the hub."""
+ if not hub.connected:
+ # While disconnected pynobo may hold stale topology; only reconcile
+ # against a live, fully-synced hub.
+ return
+ expected_identifiers = {(DOMAIN, hub.hub_serial)}
+ expected_identifiers.update(
+ (DOMAIN, f"{hub.hub_serial}:{zone_id}") for zone_id in hub.zones
+ )
+ expected_identifiers.update((DOMAIN, serial) for serial in hub.components)
+ # Runs inside pynobo's update-callback dispatch: removing a device
+ # deregisters its entities' callbacks mid-iteration, which can skip a
+ # following callback. Safe because a pynobo message carries a single
+ # topology change, so a removal never coincides with a surviving
+ # entity's update in the same dispatch.
+ for device in dr.async_entries_for_config_entry(
+ device_registry, entry.entry_id
+ ):
+ if device.identifiers.isdisjoint(expected_identifiers):
+ device_registry.async_remove_device(device.id)
+
+ _cleanup_devices(hub)
+ hub.register_callback(_cleanup_devices)
+ entry.async_on_unload(lambda: hub.deregister_callback(_cleanup_devices))
+
await hub.start()
return True
diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py
index 6cb8449a5828..aa09b8fba97f 100644
--- a/homeassistant/components/nobo_hub/climate.py
+++ b/homeassistant/components/nobo_hub/climate.py
@@ -56,8 +56,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nobø Ecohub platform from UI configuration."""
-
- # Setup connection with hub
hub = config_entry.runtime_data
override_type = (
@@ -66,8 +64,26 @@ async def async_setup_entry(
else nobo.API.OVERRIDE_TYPE_CONSTANT
)
- # Add zones as entities
- async_add_entities(NoboZone(zone_id, hub, override_type) for zone_id in hub.zones)
+ known_zones: set[str] = set()
+
+ @callback
+ def _add_zones(_hub: nobo) -> None:
+ """Add climate entities for zones added to the hub."""
+ if hub.connected:
+ # Forget zones no longer on the hub so a removed-then-re-added zone
+ # (the hub reuses zone ids) is detected as new again. Skip while
+ # disconnected: a stale/empty snapshot would drop live zones and
+ # cause duplicate re-adds on reconnect.
+ known_zones.intersection_update(hub.zones)
+ new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones]
+ known_zones.update(new_zones)
+ async_add_entities(
+ NoboZone(zone_id, hub, override_type) for zone_id in new_zones
+ )
+
+ _add_zones(hub)
+ hub.register_callback(_add_zones)
+ config_entry.async_on_unload(lambda: hub.deregister_callback(_add_zones))
class NoboZone(NoboBaseEntity, ClimateEntity):
@@ -161,15 +177,18 @@ class NoboZone(NoboBaseEntity, ClimateEntity):
"""Fetch new state data for this zone."""
self._read_state()
+ @property
+ @override
+ def available(self) -> bool:
+ """Available when the hub is connected and the zone still exists."""
+ return super().available and self._id in self._nobo.zones
+
@callback
@override
def _read_state(self) -> None:
- """Copy the current hub state onto the entity attributes."""
- if self._id not in self._nobo.zones:
- # Zone removed via the Nobø app; mark unavailable.
- self._attr_available = False
+ """Read the current state from the hub. These are only local calls."""
+ if not self.available:
return
- self._attr_available = True
state = self._nobo.get_current_zone_mode(self._id, dt_util.now())
self._attr_hvac_mode = HVACMode.AUTO
self._attr_preset_mode = PRESET_NONE
diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py
index 58a6c90e2696..ecbd23a7d487 100644
--- a/homeassistant/components/nobo_hub/config_flow.py
+++ b/homeassistant/components/nobo_hub/config_flow.py
@@ -1,12 +1,13 @@
"""Config flow for Nobø Ecohub integration."""
-import socket
+import ipaddress
from typing import TYPE_CHECKING, Any, override
-from pynobo import nobo
+from pynobo import PynoboConnectionError, nobo
import voluptuous as vol
from homeassistant.config_entries import (
+ ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
@@ -199,6 +200,68 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of an existing hub.
+
+ Only the IP address is editable. When the entry is not loaded,
+ the new IP is probed here before updating. When the entry is
+ loaded, probing is skipped to avoid competing with the active
+ connection for the hub's limited concurrent-connection slots;
+ the reload's ``async_setup_entry`` re-validates the updated IP.
+ """
+ reconfigure_entry = self._get_reconfigure_entry()
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ new_ip = user_input[CONF_IP_ADDRESS]
+ is_loaded = reconfigure_entry.state is ConfigEntryState.LOADED
+ try:
+ ipaddress.ip_address(new_ip)
+ except ValueError:
+ errors[CONF_IP_ADDRESS] = "invalid_ip"
+ else:
+ try:
+ # Probe the new IP only when the integration is not currently
+ # loaded — if it were, the running connection would compete
+ # with the probe for the hub's limited concurrent-connection
+ # slots.
+ if not is_loaded:
+ await self._test_connection(
+ reconfigure_entry.data[CONF_SERIAL], new_ip
+ )
+ except NoboHubConnectError as error:
+ # The serial is fixed in reconfigure, so blame the IP rather
+ # than the (uneditable) serial number.
+ errors[CONF_IP_ADDRESS] = (
+ "cannot_connect_ip"
+ if error.msg == "cannot_connect"
+ else error.msg
+ )
+ else:
+ if new_ip == reconfigure_entry.data[CONF_IP_ADDRESS] and is_loaded:
+ # No-op: IP unchanged and the running integration already
+ # proves it works. Skip the reload to avoid a needless
+ # reconnect.
+ return self.async_abort(reason="reconfigure_successful")
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data_updates={CONF_IP_ADDRESS: new_ip},
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema({vol.Required(CONF_IP_ADDRESS): str}),
+ user_input or reconfigure_entry.data,
+ ),
+ errors=errors,
+ description_placeholders={
+ CONF_SERIAL: reconfigure_entry.data[CONF_SERIAL],
+ },
+ )
+
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -245,19 +308,19 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN):
if len(serial) != SERIAL_LENGTH or not serial.isdigit():
raise NoboHubConnectError("invalid_serial")
try:
- socket.inet_aton(ip_address)
- except OSError as err:
+ ipaddress.ip_address(ip_address)
+ except ValueError as err:
raise NoboHubConnectError("invalid_ip") from err
hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False)
# pynobo distinguishes the two failure modes: TCP-level errors
- # (wrong IP, hub offline, port closed) raise OSError, while a
- # successful TCP connection followed by a handshake REJECT
+ # (wrong IP, hub offline, port closed) raise PynoboConnectionError,
+ # while a successful TCP connection followed by a handshake REJECT
# (serial mismatch) returns False.
try:
if not await hub.async_connect_hub(ip_address, serial):
raise NoboHubConnectError("cannot_connect")
return hub.hub_info["name"]
- except OSError as err:
+ except PynoboConnectionError as err:
raise NoboHubConnectError("cannot_connect_ip") from err
finally:
await hub.close()
diff --git a/homeassistant/components/nobo_hub/diagnostics.py b/homeassistant/components/nobo_hub/diagnostics.py
new file mode 100644
index 000000000000..7916848774cd
--- /dev/null
+++ b/homeassistant/components/nobo_hub/diagnostics.py
@@ -0,0 +1,53 @@
+"""Diagnostics support for Nobø Ecohub."""
+
+from typing import Any
+
+from pynobo import ComponentInfo, nobo
+
+from homeassistant.components.diagnostics import REDACTED, async_redact_data
+from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC
+from homeassistant.core import HomeAssistant
+
+from . import NoboHubConfigEntry
+from .const import ATTR_SERIAL, CONF_SERIAL
+
+TO_REDACT_ENTRY = {CONF_IP_ADDRESS, CONF_MAC, CONF_SERIAL}
+TO_REDACT_HUB = {ATTR_SERIAL}
+
+_MODEL_FIELDS = (
+ "model_id",
+ "name",
+ "type",
+ "has_temp_sensor",
+ "requires_control_panel",
+ "supports_comfort",
+ "supports_eco",
+)
+
+
+def _component_to_dict(component: ComponentInfo) -> dict[str, Any]:
+ model = component["model"]
+ formatted: dict[str, Any] = dict(component)
+ formatted["model"] = {field: getattr(model, field, None) for field in _MODEL_FIELDS}
+ if model.type == nobo.Model.UNKNOWN:
+ # Unknown models carry the serial number in the name.
+ formatted["model"]["name"] = REDACTED
+ return formatted
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: NoboHubConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ hub = entry.runtime_data
+ return {
+ "entry_data": async_redact_data(entry.data, TO_REDACT_ENTRY),
+ "hub_info": async_redact_data(hub.hub_info, TO_REDACT_HUB),
+ "zones": hub.zones,
+ "components": async_redact_data(
+ [_component_to_dict(c) for c in hub.components.values()],
+ TO_REDACT_HUB,
+ ),
+ "week_profiles": hub.week_profiles,
+ "overrides": hub.overrides,
+ }
diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py
index 22445ed7150e..7ad26edb40bf 100644
--- a/homeassistant/components/nobo_hub/entity.py
+++ b/homeassistant/components/nobo_hub/entity.py
@@ -17,16 +17,21 @@ class NoboBaseEntity(Entity):
def __init__(self, hub: nobo) -> None:
"""Initialize the entity."""
self._nobo = hub
+ self._attr_available = hub.connected
@override
async def async_added_to_hass(self) -> None:
- """Register callback with hub."""
+ """Register callbacks with hub."""
await super().async_added_to_hass()
self._nobo.register_callback(self._handle_hub_update)
+ self._nobo.register_connection_callback(self._handle_hub_connection)
+ # Resync in case the state changed between __init__ and callback registration.
+ self._attr_available = self._nobo.connected
@override
async def async_will_remove_from_hass(self) -> None:
- """Deregister callback from hub."""
+ """Deregister callbacks from hub."""
+ self._nobo.deregister_connection_callback(self._handle_hub_connection)
self._nobo.deregister_callback(self._handle_hub_update)
await super().async_will_remove_from_hass()
@@ -36,6 +41,16 @@ class NoboBaseEntity(Entity):
self._read_state()
self.async_write_ha_state()
+ @callback
+ def _handle_hub_connection(self, _hub: nobo, connected: bool) -> None:
+ """Handle a connection-state transition from the hub."""
+ self._attr_available = connected
+ if connected:
+ # Refresh state values so the first state write after reconnect
+ # carries fresh data, not whatever was cached pre-disconnect.
+ self._read_state()
+ self.async_write_ha_state()
+
@callback
def _read_state(self) -> None:
"""Copy the current hub state from the pynobo client onto the entity attributes.
diff --git a/homeassistant/components/nobo_hub/icons.json b/homeassistant/components/nobo_hub/icons.json
new file mode 100644
index 000000000000..74c20b18fe5b
--- /dev/null
+++ b/homeassistant/components/nobo_hub/icons.json
@@ -0,0 +1,17 @@
+{
+ "entity": {
+ "select": {
+ "global_override": {
+ "default": "mdi:calendar-clock",
+ "state": {
+ "away": "mdi:account-arrow-right",
+ "comfort": "mdi:sofa",
+ "eco": "mdi:leaf"
+ }
+ },
+ "week_profile": {
+ "default": "mdi:calendar-clock"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json
index 13d7dfd60794..3350742c38d5 100644
--- a/homeassistant/components/nobo_hub/manifest.json
+++ b/homeassistant/components/nobo_hub/manifest.json
@@ -15,6 +15,6 @@
"documentation": "https://www.home-assistant.io/integrations/nobo_hub",
"integration_type": "hub",
"iot_class": "local_push",
- "quality_scale": "bronze",
+ "quality_scale": "gold",
"requirements": ["pynobo==1.9.0"]
}
diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml
index bd6a427a0c28..fb94595ed48f 100644
--- a/homeassistant/components/nobo_hub/quality_scale.yaml
+++ b/homeassistant/components/nobo_hub/quality_scale.yaml
@@ -11,7 +11,7 @@ rules:
dependency-transparency: done
docs-actions:
status: exempt
- comment: Integration does not register custom actions.
+ comment: This integration does not register custom actions.
docs-conditions:
status: exempt
comment: This integration does not have any conditions.
@@ -34,52 +34,52 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
- entity-unavailable: todo
+ entity-unavailable: done
integration-owner: done
- log-when-unavailable: todo
+ log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: The hub does not require authentication.
- test-coverage:
- status: done
- comment: >
- Investigate whether the `_spec_hub` helper in `test_init.py` can be
- replaced by the conftest base mock.
+ test-coverage: done
# Gold
devices:
status: done
comment: >
Model name "Nobø Ecohub" under review for rename to "Nobø Hub".
- diagnostics: todo
+ diagnostics: done
discovery: done
discovery-update-info: 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: todo
- entity-category: todo
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: done
+ entity-category:
+ status: exempt
+ comment: >
+ All entities are primary controls or measurements; none are configuration
+ or diagnostic entities that need a non-default entity category.
entity-device-class: done
- entity-disabled-by-default: todo
- entity-translations: todo
- exception-translations: todo
- icon-translations: todo
- reconfiguration-flow: todo
+ entity-disabled-by-default:
+ status: exempt
+ comment: This integration has no entities that should be disabled by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
repair-issues:
status: exempt
- comment: Integration has no repair scenarios.
- stale-devices: todo
+ comment: This integration has no repair scenarios.
+ stale-devices: done
# Platinum
async-dependency: done
inject-websession:
status: exempt
- comment: Integration uses a local TCP socket (via pynobo); no HTTP client is used.
- strict-typing:
- status: todo
- comment: Requires release of pynobo 1.9.0
+ comment: This integration uses a local TCP socket (via pynobo); no HTTP client is used.
+ strict-typing: todo
diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py
index a4850fc6c76d..85ad51e78e04 100644
--- a/homeassistant/components/nobo_hub/select.py
+++ b/homeassistant/components/nobo_hub/select.py
@@ -32,8 +32,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up any temperature sensors connected to the Nobø Ecohub."""
-
- # Setup connection with hub
hub = config_entry.runtime_data
override_type = (
@@ -42,11 +40,28 @@ async def async_setup_entry(
else nobo.API.OVERRIDE_TYPE_CONSTANT
)
- entities: list[SelectEntity] = [
- NoboProfileSelector(zone_id, hub) for zone_id in hub.zones
- ]
- entities.append(NoboGlobalSelector(hub, override_type))
- async_add_entities(entities, True)
+ async_add_entities([NoboGlobalSelector(hub, override_type)], True)
+
+ known_zones: set[str] = set()
+
+ @callback
+ def _add_profiles(_hub: nobo) -> None:
+ """Add week-profile selectors for zones added to the hub."""
+ if hub.connected:
+ # Forget zones no longer on the hub so a removed-then-re-added zone
+ # (the hub reuses zone ids) is detected as new again. Skip while
+ # disconnected: a stale/empty snapshot would drop live zones and
+ # cause duplicate re-adds on reconnect.
+ known_zones.intersection_update(hub.zones)
+ new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones]
+ known_zones.update(new_zones)
+ async_add_entities(
+ (NoboProfileSelector(zone_id, hub) for zone_id in new_zones), True
+ )
+
+ _add_profiles(hub)
+ hub.register_callback(_add_profiles)
+ config_entry.async_on_unload(lambda: hub.deregister_callback(_add_profiles))
class NoboGlobalSelector(NoboBaseEntity, SelectEntity):
@@ -143,15 +158,18 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity):
"""Fetch new state data for this zone."""
self._read_state()
+ @property
+ @override
+ def available(self) -> bool:
+ """Available when the hub is connected and the zone still exists."""
+ return super().available and self._id in self._nobo.zones
+
@callback
@override
def _read_state(self) -> None:
- """Copy the current hub state onto the entity attributes."""
- if self._id not in self._nobo.zones:
- # Zone removed via the Nobø app; mark unavailable.
- self._attr_available = False
+ """Read the current state from the hub. These are only local calls."""
+ if not self.available:
return
- self._attr_available = True
self._profiles = {
profile["week_profile_id"]: profile["name"].replace("\xa0", " ")
for profile in self._nobo.week_profiles.values()
diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py
index c82718449035..371fa96e6823 100644
--- a/homeassistant/components/nobo_hub/sensor.py
+++ b/homeassistant/components/nobo_hub/sensor.py
@@ -28,15 +28,32 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up any temperature sensors connected to the Nobø Ecohub."""
-
- # Setup connection with hub
hub = config_entry.runtime_data
- async_add_entities(
- NoboTemperatureSensor(component["serial"], hub)
- for component in hub.components.values()
- if component[ATTR_MODEL].has_temp_sensor
- )
+ known_components: set[str] = set()
+
+ @callback
+ def _add_sensors(_hub: nobo) -> None:
+ """Add temperature sensors for components added to the hub."""
+ if hub.connected:
+ # Forget components no longer on the hub so a removed-then-re-added
+ # component is detected as new again. Skip while disconnected: a
+ # stale/empty snapshot would drop live components and cause
+ # duplicate re-adds on reconnect.
+ known_components.intersection_update(hub.components)
+ new_components = [
+ serial
+ for serial, component in hub.components.items()
+ if component[ATTR_MODEL].has_temp_sensor and serial not in known_components
+ ]
+ known_components.update(new_components)
+ async_add_entities(
+ NoboTemperatureSensor(serial, hub) for serial in new_components
+ )
+
+ _add_sensors(hub)
+ hub.register_callback(_add_sensors)
+ config_entry.async_on_unload(lambda: hub.deregister_callback(_add_sensors))
class NoboTemperatureSensor(NoboBaseEntity, SensorEntity):
@@ -69,14 +86,17 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity):
)
self._read_state()
+ @property
+ @override
+ def available(self) -> bool:
+ """Available when the hub is connected and the component still exists."""
+ return super().available and self._id in self._nobo.components
+
@callback
@override
def _read_state(self) -> None:
- """Copy the current hub state onto the entity attributes."""
- if self._id not in self._nobo.components:
- # Component removed via the Nobø app; mark unavailable.
- self._attr_available = False
+ """Read the current state from the hub. This is a local call."""
+ if not self.available:
return
- self._attr_available = True
value = self._nobo.get_current_component_temperature(self._id)
self._attr_native_value = None if value is None else float(value)
diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json
index 07cd3d15c269..4725b8cb05d8 100644
--- a/homeassistant/components/nobo_hub/strings.json
+++ b/homeassistant/components/nobo_hub/strings.json
@@ -3,7 +3,8 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address."
+ "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address.",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "Failed to connect - check serial number",
@@ -24,6 +25,15 @@
},
"description": "Configure a Nobø Ecohub not discovered on your local network. If your hub is on another network, you can still connect to it by entering the complete serial number (12 digits) and its IP address."
},
+ "reconfigure": {
+ "data": {
+ "ip_address": "[%key:common::config_flow::data::ip%]"
+ },
+ "data_description": {
+ "ip_address": "[%key:component::nobo_hub::config::step::manual::data_description::ip_address%]"
+ },
+ "description": "Update the IP address for Nobø Ecohub with serial number {serial}."
+ },
"selected": {
"data": {
"serial_suffix": "Serial number suffix (3 digits)"
@@ -62,7 +72,7 @@
},
"exceptions": {
"cannot_connect": {
- "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, remove and re-add the integration."
+ "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, reconfigure the integration with the new IP address."
},
"set_global_override_failed": {
"message": "Failed to set global override."
diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py
index 2b744e01d0da..6937f9f82022 100644
--- a/homeassistant/components/nordpool/__init__.py
+++ b/homeassistant/components/nordpool/__init__.py
@@ -66,6 +66,4 @@ async def cleanup_device(
continue
LOGGER.debug("Removing device %s", entry.name)
- device_reg.async_update_device(
- entry.id, remove_config_entry_id=config_entry.entry_id
- )
+ device_reg.async_remove_device(entry.id)
diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py
index 01872567d2ed..2366ef3cf6d0 100644
--- a/homeassistant/components/nordpool/coordinator.py
+++ b/homeassistant/components/nordpool/coordinator.py
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from typing import TYPE_CHECKING, override
import aiohttp
+from aiozoneinfo import get_time_zone
from pynordpool import (
Currency,
DeliveryPeriodData,
@@ -27,6 +28,13 @@ from .const import CONF_AREAS, DOMAIN, LOGGER
if TYPE_CHECKING:
from . import NordPoolConfigEntry
+NORDPOOL_TIMEZONE = get_time_zone("Europe/Oslo")
+
+
+def get_nordpool_current_time() -> datetime:
+ """Return the Nord Pool current time."""
+ return dt_util.utcnow().astimezone(NORDPOOL_TIMEZONE)
+
class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
"""A Nord Pool Data Update Coordinator."""
@@ -47,26 +55,20 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
def get_next_data_interval(self, now: datetime) -> datetime:
"""Compute next time an update should occur."""
- next_hour = dt_util.utcnow() + timedelta(hours=1)
- next_run = datetime(
- next_hour.year,
- next_hour.month,
- next_hour.day,
- next_hour.hour,
- tzinfo=dt_util.UTC,
- )
- LOGGER.debug("Next data update at %s", next_run)
+ next_data_run = now + timedelta(hours=1)
+ next_run = next_data_run.replace(minute=0, second=0, microsecond=0)
+ LOGGER.debug("Next data update at %s", next_run.astimezone(NORDPOOL_TIMEZONE))
return next_run
def get_next_15_interval(self, now: datetime) -> datetime:
"""Compute next time we need to notify listeners."""
- next_run = dt_util.utcnow() + timedelta(minutes=15)
+ next_run = now + timedelta(minutes=15)
next_minute = next_run.minute // 15 * 15
- next_run = next_run.replace(
- minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC
- )
+ next_run = next_run.replace(minute=next_minute, second=0, microsecond=0)
- LOGGER.debug("Next listener update at %s", next_run)
+ LOGGER.debug(
+ "Next listener update at %s", next_run.astimezone(NORDPOOL_TIMEZONE)
+ )
return next_run
@override
@@ -85,14 +87,14 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
self.listener_unsub = async_track_point_in_utc_time(
self.hass,
self.update_listeners,
- self.get_next_15_interval(dt_util.utcnow()),
+ self.get_next_15_interval(now),
)
self.async_update_listeners()
async def fetch_data(self, now: datetime, initial: bool = False) -> None:
"""Fetch data from Nord Pool."""
self.data_unsub = async_track_point_in_utc_time(
- self.hass, self.fetch_data, self.get_next_data_interval(dt_util.utcnow())
+ self.hass, self.fetch_data, self.get_next_data_interval(now)
)
if self.config_entry.pref_disable_polling and not initial:
return
@@ -107,7 +109,7 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
"""Fetch data from Nord Pool."""
data = await self.api_call()
if data and data.entries:
- current_day = dt_util.now().date()
+ current_day = get_nordpool_current_time().date()
if current_day in data.entries:
LOGGER.debug("Data for current day found")
return data
@@ -129,9 +131,9 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
try:
data = await self.client.async_get_delivery_periods(
[
- dt_util.now() - timedelta(days=1),
- dt_util.now(),
- dt_util.now() + timedelta(days=1),
+ get_nordpool_current_time() - timedelta(days=1),
+ get_nordpool_current_time(),
+ get_nordpool_current_time() + timedelta(days=1),
],
Currency(self.config_entry.data[CONF_CURRENCY]),
self.config_entry.data[CONF_AREAS],
@@ -164,10 +166,10 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
def get_data_current_day(self) -> DeliveryPeriodData:
"""Return the current day data."""
- current_day = dt_util.now().date()
+ current_day = get_nordpool_current_time().date()
return self.data.entries[current_day]
def get_data_tomorrow(self) -> DeliveryPeriodData | None:
"""Return tomorrow's day data if available."""
- tomorrow = dt_util.now().date() + timedelta(days=1)
+ tomorrow = get_nordpool_current_time().date() + timedelta(days=1)
return self.data.entries.get(tomorrow)
diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py
index 41d86462055c..96d6001cee36 100644
--- a/homeassistant/components/novy_cooker_hood/entity.py
+++ b/homeassistant/components/novy_cooker_hood/entity.py
@@ -1,76 +1,24 @@
"""Common entity for the Novy Cooker Hood integration."""
-import logging
-from typing import override
-
+from homeassistant.components.radio_frequency import (
+ RadioFrequencyTransmitterConsumerEntity,
+)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import STATE_UNAVAILABLE
-from homeassistant.core import Event, EventStateChangedData, callback
-from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.event import async_track_state_change_event
-from .const import CONF_TRANSMITTER, DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
+from .const import DOMAIN
-class NovyCookerHoodEntity(Entity):
+class NovyCookerHoodEntity(RadioFrequencyTransmitterConsumerEntity):
"""Novy Cooker Hood base entity."""
_attr_assumed_state = True
_attr_has_entity_name = True
- _attr_should_poll = False
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the entity."""
- self._transmitter = entry.data[CONF_TRANSMITTER]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Novy",
model="Cooker Hood",
)
-
- @override
- async def async_added_to_hass(self) -> None:
- """Subscribe to transmitter entity state changes."""
- await super().async_added_to_hass()
-
- transmitter_entity_id = er.async_validate_entity_id(
- er.async_get(self.hass), self._transmitter
- )
-
- @callback
- def _async_transmitter_state_changed(
- event: Event[EventStateChangedData],
- ) -> None:
- """Handle transmitter entity state changes."""
- new_state = event.data["new_state"]
- transmitter_available = (
- new_state is not None and new_state.state != STATE_UNAVAILABLE
- )
- if transmitter_available != self.available:
- _LOGGER.info(
- "Transmitter %s used by %s is %s",
- transmitter_entity_id,
- self.entity_id,
- "available" if transmitter_available else "unavailable",
- )
-
- self._attr_available = transmitter_available
- self.async_write_ha_state()
-
- self.async_on_remove(
- async_track_state_change_event(
- self.hass,
- [transmitter_entity_id],
- _async_transmitter_state_changed,
- )
- )
-
- transmitter_state = self.hass.states.get(transmitter_entity_id)
- self._attr_available = (
- transmitter_state is not None
- and transmitter_state.state != STATE_UNAVAILABLE
- )
diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py
index f59281e6c7e3..eef061011034 100644
--- a/homeassistant/components/novy_cooker_hood/fan.py
+++ b/homeassistant/components/novy_cooker_hood/fan.py
@@ -1,13 +1,17 @@
"""Fan platform for the Novy Cooker Hood (calibrated speed control)."""
+import asyncio
import math
from typing import Any, override
+from rf_protocols import RadioFrequencyCommand
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
-from rf_protocols.commands.novy import NovyCookerHoodCommand
-from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
-from homeassistant.components.radio_frequency import async_send_command
+from homeassistant.components.fan import (
+ FanEntity,
+ FanEntityFeature,
+ FanEntityStateAttribute,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CODE
from homeassistant.core import HomeAssistant
@@ -18,13 +22,18 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage,
)
-from .const import SPEED_COUNT
+from .const import CONF_TRANSMITTER, SPEED_COUNT
from .entity import NovyCookerHoodEntity
PARALLEL_UPDATES = 1
_SPEED_RANGE = (1, SPEED_COUNT)
+# Minimum gap the hood needs to register consecutive presses as distinct
+# button events. Without it, low-latency transmitters collapse rapid presses
+# into a single one.
+_COMMAND_DELAY = 0.5
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -49,6 +58,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the fan."""
super().__init__(entry)
+ self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER]
self._code: int = entry.data[CONF_CODE]
self._level = 0
self._attr_unique_id = entry.entry_id
@@ -74,7 +84,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
last = await self.async_get_last_state()
if last is None:
return
- last_pct = last.attributes.get(ATTR_PERCENTAGE)
+ last_pct = last.attributes.get(FanEntityStateAttribute.PERCENTAGE)
if isinstance(last_pct, (int, float)) and last_pct > 0:
self._level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, last_pct))
@@ -111,8 +121,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
"""Bump speed up by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code)
- for _ in range(steps):
- await self._async_send(plus)
+ await self._async_send_repeated(plus, steps)
self._level = min(SPEED_COUNT, self._level + steps)
self.async_write_ha_state()
@@ -121,8 +130,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
"""Bump speed down by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code)
- for _ in range(steps):
- await self._async_send(minus)
+ await self._async_send_repeated(minus, steps)
self._level = max(0, self._level - steps)
self.async_write_ha_state()
@@ -136,17 +144,19 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def _async_set_level(self, level: int) -> None:
"""Reset to off with `SPEED_COUNT` minus presses, then climb to level."""
minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code)
- for _ in range(SPEED_COUNT):
- await self._async_send(minus)
+ await self._async_send_repeated(minus, SPEED_COUNT)
if level > 0:
+ await asyncio.sleep(_COMMAND_DELAY)
plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code)
- for _ in range(level):
- await self._async_send(plus)
+ await self._async_send_repeated(plus, level)
self._level = level
self.async_write_ha_state()
- async def _async_send(self, command: NovyCookerHoodCommand) -> None:
- """Send a single RF command via the configured transmitter."""
- await async_send_command(
- self.hass, self._transmitter, command, context=self._context
- )
+ async def _async_send_repeated(
+ self, command: RadioFrequencyCommand, count: int
+ ) -> None:
+ """Send the same RF command N times, pausing between presses."""
+ for i in range(count):
+ if i > 0:
+ await asyncio.sleep(_COMMAND_DELAY)
+ await self._send_command(command)
diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py
index 597c4873fcad..8a456b396723 100644
--- a/homeassistant/components/novy_cooker_hood/light.py
+++ b/homeassistant/components/novy_cooker_hood/light.py
@@ -5,13 +5,13 @@ from typing import Any, override
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from homeassistant.components.light import ColorMode, LightEntity
-from homeassistant.components.radio_frequency import async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CODE, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
+from .const import CONF_TRANSMITTER
from .entity import NovyCookerHoodEntity
PARALLEL_UPDATES = 1
@@ -36,6 +36,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the light."""
super().__init__(entry)
+ self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER]
self._code = entry.data[CONF_CODE]
self._attr_unique_id = entry.entry_id
@@ -63,6 +64,4 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
async def _async_send_light(self) -> None:
"""Send the light toggle command via the configured transmitter."""
command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code)
- await async_send_command(
- self.hass, self._transmitter, command, context=self._context
- )
+ await self._send_command(command)
diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py
index 1bda5ab4e4d5..06b965ae3cb6 100644
--- a/homeassistant/components/nut/diagnostics.py
+++ b/homeassistant/components/nut/diagnostics.py
@@ -2,9 +2,11 @@
from typing import Any
-import attr
-
-from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.components.diagnostics import (
+ async_redact_data,
+ device_entry_as_dict,
+ entity_entry_as_dict,
+)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -41,7 +43,7 @@ async def async_get_config_entry_diagnostics(
assert hass_device is not None
data["device"] = {
- **attr.asdict(hass_device),
+ **device_entry_as_dict(hass_device),
"entities": {},
}
@@ -61,10 +63,11 @@ async def async_get_config_entry_diagnostics(
# The context doesn't provide useful information in this case.
state_dict.pop("context", None)
+ entity_dict = entity_entry_as_dict(entity_entry)
+ # The entity_id is already provided at root level (the key).
+ del entity_dict["entity_id"]
data["device"]["entities"][entity_entry.entity_id] = {
- **attr.asdict(
- entity_entry, filter=lambda attr, value: attr.name != "entity_id"
- ),
+ **entity_dict,
"state": state_dict,
}
diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py
index 80b6e67aa217..eebc2751dfab 100644
--- a/homeassistant/components/nws/__init__.py
+++ b/homeassistant/components/nws/__init__.py
@@ -9,11 +9,10 @@ from pynws import NwsNoDataError, SimpleNWS, call_with_retry
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
+ EntityStateAttribute,
Platform,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
@@ -102,8 +101,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool:
translation_key="entity_unavailable",
translation_placeholders={"entity_id": location_entity_id},
)
- latitude = state.attributes[ATTR_LATITUDE]
- longitude = state.attributes[ATTR_LONGITUDE]
+ latitude = state.attributes[EntityStateAttribute.LATITUDE]
+ longitude = state.attributes[EntityStateAttribute.LONGITUDE]
station = None
else:
latitude = entry.data[CONF_LATITUDE]
@@ -217,8 +216,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool:
new_state = event.data["new_state"]
if new_state is None or not has_location(new_state):
return
- new_lat = new_state.attributes[ATTR_LATITUDE]
- new_lon = new_state.attributes[ATTR_LONGITUDE]
+ new_lat = new_state.attributes[EntityStateAttribute.LATITUDE]
+ new_lon = new_state.attributes[EntityStateAttribute.LONGITUDE]
if (
new_lat == entry.runtime_data.latitude
and new_lon == entry.runtime_data.longitude
diff --git a/homeassistant/components/nws/config_flow.py b/homeassistant/components/nws/config_flow.py
index b1b5e237b07b..e107d5f54215 100644
--- a/homeassistant/components/nws/config_flow.py
+++ b/homeassistant/components/nws/config_flow.py
@@ -9,11 +9,10 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -134,8 +133,12 @@ class NWSConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass,
{
CONF_API_KEY: user_input[CONF_API_KEY],
- CONF_LATITUDE: state.attributes[ATTR_LATITUDE],
- CONF_LONGITUDE: state.attributes[ATTR_LONGITUDE],
+ CONF_LATITUDE: state.attributes[
+ EntityStateAttribute.LATITUDE
+ ],
+ CONF_LONGITUDE: state.attributes[
+ EntityStateAttribute.LONGITUDE
+ ],
},
)
return self.async_create_entry(title=location_entity, data=data)
diff --git a/homeassistant/components/nws/coordinator.py b/homeassistant/components/nws/coordinator.py
index a838907823f2..4824cbd85217 100644
--- a/homeassistant/components/nws/coordinator.py
+++ b/homeassistant/components/nws/coordinator.py
@@ -8,7 +8,7 @@ import aiohttp
from aiohttp import ClientResponseError
from pynws import NwsError, NwsNoDataError, SimpleNWS, call_with_retry
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY
+from homeassistant.const import CONF_API_KEY, EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers import debounce
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -90,8 +90,8 @@ class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]):
self._location_entity_id,
)
return
- new_lat = state.attributes[ATTR_LATITUDE]
- new_lon = state.attributes[ATTR_LONGITUDE]
+ new_lat = state.attributes[EntityStateAttribute.LATITUDE]
+ new_lon = state.attributes[EntityStateAttribute.LONGITUDE]
if self._previous_position is not None:
prev_lat, prev_lon = self._previous_position
if new_lat == prev_lat and new_lon == prev_lon:
diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py
index 86d7d964c3a5..1bc5c85925e4 100644
--- a/homeassistant/components/ohme/sensor.py
+++ b/homeassistant/components/ohme/sensor.py
@@ -17,7 +17,6 @@ from homeassistant.const import (
STATE_UNKNOWN,
UnitOfElectricCurrent,
UnitOfElectricPotential,
- UnitOfEnergy,
UnitOfPower,
)
from homeassistant.core import HomeAssistant
@@ -60,15 +59,6 @@ SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda client: client.power.watts,
),
- OhmeSensorDescription(
- key="energy",
- device_class=SensorDeviceClass.ENERGY,
- native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
- suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
- suggested_display_precision=1,
- state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda client: client.energy,
- ),
OhmeSensorDescription(
key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py
index 16717b66d4d2..7ee31e4e8662 100644
--- a/homeassistant/components/ollama/__init__.py
+++ b/homeassistant/components/ollama/__init__.py
@@ -26,7 +26,7 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from homeassistant.util.ssl import get_default_context
from .const import (
@@ -192,7 +192,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
# Device and entity registries will set the disabled_by flag to None
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
# config entry, but we want to set it to USER instead,
- device_disabled_by = device.disabled_by
+ device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
and not all_disabled
@@ -202,20 +202,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
disabled_by=device_disabled_by,
new_identifiers={(DOMAIN, subentry.subentry_id)},
- add_config_subentry_id=subentry.subentry_id,
- add_config_entry_id=parent_entry.entry_id,
+ new_config_entry_id=parent_entry.entry_id,
+ new_config_subentry_id=subentry.subentry_id,
)
- if parent_entry.entry_id != entry.entry_id:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- )
- else:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- remove_config_subentry_id=None,
- )
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py
index 78a3b3a9636f..19f79bff7a0f 100644
--- a/homeassistant/components/ollama/config_flow.py
+++ b/homeassistant/components/ollama/config_flow.py
@@ -118,7 +118,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
str(err),
)
errors["base"] = "unknown"
- except TimeoutError, httpx.ConnectError:
+ except TimeoutError, ConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
@@ -278,7 +278,7 @@ class OllamaSubentryFlowHandler(ConfigSubentryFlow):
downloaded_models: set[str] = {
model_info["model"] for model_info in response.get("models", [])
}
- except TimeoutError, httpx.ConnectError, httpx.HTTPError:
+ except TimeoutError, httpx.HTTPError, ConnectionError:
_LOGGER.exception("Failed to get models from Ollama server")
return self.async_abort(reason="cannot_connect")
diff --git a/homeassistant/components/omie/__init__.py b/homeassistant/components/omie/__init__.py
index a0e1334ff4c9..e4bade0040f5 100644
--- a/homeassistant/components/omie/__init__.py
+++ b/homeassistant/components/omie/__init__.py
@@ -2,11 +2,23 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+from .const import DOMAIN
from .coordinator import OMIEConfigEntry, OMIECoordinator
+from .services import async_setup_services
PLATFORMS = [Platform.SENSOR]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the OMIE integration."""
+ async_setup_services(hass)
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: OMIEConfigEntry) -> bool:
"""Set up from a config entry."""
diff --git a/homeassistant/components/omie/coordinator.py b/homeassistant/components/omie/coordinator.py
index 0538e42fcd8e..a69731662bd1 100644
--- a/homeassistant/components/omie/coordinator.py
+++ b/homeassistant/components/omie/coordinator.py
@@ -47,7 +47,7 @@ class OMIECoordinator(DataUpdateCoordinator[OMIEResults[SpotData]]):
if self.data and self.data.market_date == cet_today:
data = self.data
else:
- data = await self._spot_price(cet_today)
+ data = await self.async_get_spot_price(cet_today)
self._set_update_interval()
return data
@@ -58,7 +58,7 @@ class OMIECoordinator(DataUpdateCoordinator[OMIEResults[SpotData]]):
self.update_interval = calc_update_interval(now)
_LOGGER.debug("Next refresh at %s", (now + self.update_interval).isoformat())
- async def _spot_price(self, date: dt.date) -> OMIEResults[SpotData]:
+ async def async_get_spot_price(self, date: dt.date) -> OMIEResults[SpotData]:
"""Fetch OMIE spot price data for the given date."""
_LOGGER.debug("Fetching OMIE spot data for %s", date)
return await pyomie.spot_price(self._client_session, date)
diff --git a/homeassistant/components/omie/icons.json b/homeassistant/components/omie/icons.json
new file mode 100644
index 000000000000..460e22df9c06
--- /dev/null
+++ b/homeassistant/components/omie/icons.json
@@ -0,0 +1,7 @@
+{
+ "services": {
+ "get_prices_for_date": {
+ "service": "mdi:cash-multiple"
+ }
+ }
+}
diff --git a/homeassistant/components/omie/quality_scale.yaml b/homeassistant/components/omie/quality_scale.yaml
index 5290b86a3672..e9eec6e01950 100644
--- a/homeassistant/components/omie/quality_scale.yaml
+++ b/homeassistant/components/omie/quality_scale.yaml
@@ -1,17 +1,13 @@
rules:
# Bronze
- action-setup:
- status: exempt
- comment: No custom service actions are defined.
+ 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: No custom service actions are defined.
+ docs-actions: done
docs-conditions:
status: exempt
comment: This integration does not have any conditions.
@@ -35,9 +31,7 @@ rules:
Coordinators handle any connection issues gracefully during runtime.
unique-config-entry: done
# Silver
- action-exceptions:
- status: exempt
- comment: No custom service actions are defined.
+ action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
@@ -49,3 +43,29 @@ rules:
status: exempt
comment: OMIE API is public data service that doesn't require authentication.
test-coverage: done
+ # Gold
+ devices: todo
+ diagnostics: todo
+ discovery: todo
+ discovery-update-info: 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: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/omie/services.py b/homeassistant/components/omie/services.py
new file mode 100644
index 000000000000..9dc3eab60a37
--- /dev/null
+++ b/homeassistant/components/omie/services.py
@@ -0,0 +1,119 @@
+"""Services for the OMIE - Spain and Portugal electricity prices integration."""
+
+import datetime as dt
+from enum import StrEnum
+from typing import Final
+
+import aiohttp
+from pyomie import QUARTER_HOURLY_START_DATE
+import voluptuous as vol
+
+from homeassistant.const import ATTR_DATE
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+ callback,
+)
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN
+from .coordinator import OMIEConfigEntry
+from .util import CET, pick_series_cet
+
+ATTR_COUNTRIES: Final = "countries"
+
+
+class Country(StrEnum):
+ """Country to retrieve prices for."""
+
+ ES = "es"
+ PT = "pt"
+
+
+SERVICE_GET_PRICES_FOR_DATE: Final = "get_prices_for_date"
+SERVICE_GET_PRICES_SCHEMA: Final = vol.Schema(
+ {
+ vol.Required(ATTR_DATE): cv.date,
+ vol.Required(ATTR_COUNTRIES, default=[Country.ES, Country.PT]): vol.All(
+ cv.ensure_list, [vol.Coerce(Country)]
+ ),
+ }
+)
+
+_QUARTER_HOUR: Final = dt.timedelta(minutes=15)
+_SERIES_BY_COUNTRY: Final = {Country.ES: "es_spot_price", Country.PT: "pt_spot_price"}
+
+
+async def _get_prices_for_date(call: ServiceCall) -> ServiceResponse:
+ """Get OMIE spot prices for a specific date."""
+ loaded_entries: list[OMIEConfigEntry] = (
+ call.hass.config_entries.async_loaded_entries(DOMAIN)
+ )
+ if not loaded_entries:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="entry_not_loaded",
+ )
+ entry = loaded_entries[0]
+ market_date: dt.date = call.data[ATTR_DATE]
+ countries: list[Country] = call.data[ATTR_COUNTRIES]
+
+ if market_date < QUARTER_HOURLY_START_DATE:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="date_before_market_start",
+ translation_placeholders={
+ "date": market_date.isoformat(),
+ "start_date": QUARTER_HOURLY_START_DATE.isoformat(),
+ },
+ )
+
+ try:
+ results = await entry.runtime_data.async_get_spot_price(market_date)
+ except aiohttp.ClientResponseError as err:
+ if err.status == 404:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="data_not_available",
+ translation_placeholders={"date": market_date.isoformat()},
+ ) from err
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ ) from err
+ except (aiohttp.ClientError, TimeoutError) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ ) from err
+
+ return {
+ country.value: [
+ {
+ "start": start.isoformat(),
+ # Add in UTC otherwise the end will be wrong across DST changes
+ "end": (start.astimezone(dt.UTC) + _QUARTER_HOUR)
+ .astimezone(CET)
+ .isoformat(),
+ "price": price_mwh / 1000,
+ }
+ for start, price_mwh in pick_series_cet(results, series_name).items()
+ ]
+ for country, series_name in _SERIES_BY_COUNTRY.items()
+ if country in countries
+ }
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Set up the services for the OMIE integration."""
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GET_PRICES_FOR_DATE,
+ _get_prices_for_date,
+ schema=SERVICE_GET_PRICES_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/omie/services.yaml b/homeassistant/components/omie/services.yaml
new file mode 100644
index 000000000000..b862e194bfa6
--- /dev/null
+++ b/homeassistant/components/omie/services.yaml
@@ -0,0 +1,18 @@
+get_prices_for_date:
+ fields:
+ date:
+ required: true
+ selector:
+ date:
+ countries:
+ required: true
+ default:
+ - es
+ - pt
+ selector:
+ select:
+ multiple: true
+ options:
+ - es
+ - pt
+ translation_key: country
diff --git a/homeassistant/components/omie/strings.json b/homeassistant/components/omie/strings.json
index fe6e2f85e5bc..1646d7a856b8 100644
--- a/homeassistant/components/omie/strings.json
+++ b/homeassistant/components/omie/strings.json
@@ -21,5 +21,43 @@
"name": "Portugal spot price"
}
}
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "Error connecting to the OMIE API."
+ },
+ "data_not_available": {
+ "message": "Prices for {date} have not been published yet."
+ },
+ "date_before_market_start": {
+ "message": "No quarter-hourly prices exist for {date}. Prices are available from {start_date} onwards."
+ },
+ "entry_not_loaded": {
+ "message": "The OMIE integration is not loaded."
+ }
+ },
+ "selector": {
+ "country": {
+ "options": {
+ "es": "Spain",
+ "pt": "Portugal"
+ }
+ }
+ },
+ "services": {
+ "get_prices_for_date": {
+ "description": "Retrieves the electricity spot prices (in €/kWh) for a specific date. Prices for the next day are published daily at around 13:30 CET.",
+ "fields": {
+ "countries": {
+ "description": "The countries to get the prices for.",
+ "name": "Countries"
+ },
+ "date": {
+ "description": "The date to get the prices for.",
+ "name": "Date"
+ }
+ },
+ "name": "Get prices for date"
+ }
}
}
diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py
index 1693454f3864..fdd69d2f5fd6 100644
--- a/homeassistant/components/onedrive/services.py
+++ b/homeassistant/components/onedrive/services.py
@@ -2,6 +2,7 @@
import asyncio
from dataclasses import asdict
+import glob
from pathlib import Path, PurePosixPath
from typing import cast
@@ -48,20 +49,82 @@ DELETE_SERVICE_SCHEMA = vol.Schema(
CONTENT_SIZE_LIMIT = 250 * 1024 * 1024
-def _read_file_contents(
+def _split_glob_pattern(pattern: str) -> tuple[str, str]:
+ """Split a glob pattern into its non-magic base directory and remaining pattern."""
+ parts = Path(pattern).parts
+ base_parts: list[str] = []
+ for part in parts:
+ if glob.has_magic(part):
+ break
+ base_parts.append(part)
+ base = str(Path(*base_parts)) if base_parts else "."
+ relative_pattern = str(Path(*parts[len(base_parts) :]))
+ return base, relative_pattern
+
+
+def _expand_filenames(
hass: HomeAssistant, filenames: list[str]
-) -> list[tuple[str, bytes]]:
- """Return the mime types and file contents for each file."""
- missing: list[str] = []
+) -> list[tuple[str, str]]:
+ """Expand wildcard patterns, preserving subfolder structure."""
+ expanded: dict[str, str] = {}
+ no_matches: list[str] = []
for filename in filenames:
- if not hass.config.is_allowed_path(filename):
+ if not glob.has_magic(filename) or Path(filename).is_file():
+ expanded.setdefault(filename, Path(filename).name)
+ continue
+ base, relative_pattern = _split_glob_pattern(filename)
+
+ if not hass.config.is_allowed_path(base):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="no_access_to_path",
- translation_placeholders={"filename": filename},
+ translation_placeholders={"filename": base},
)
- if not Path(filename).exists():
- missing.append(filename)
+ matches = sorted(
+ match
+ for match in glob.glob(relative_pattern, root_dir=base, recursive=True)
+ if (Path(base) / match).is_file()
+ )
+ if not matches:
+ no_matches.append(filename)
+ continue
+ for match in matches:
+ full_path = str(Path(base) / match)
+ relative_path = str(PurePosixPath(*Path(match).parts))
+ expanded.setdefault(full_path, relative_path)
+ if no_matches:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="no_files_match_pattern",
+ translation_placeholders={
+ "patterns": ", ".join(f"`{p}`" for p in no_matches)
+ },
+ )
+ return list(expanded.items())
+
+
+def _destination_parts(relative_path: str) -> tuple[str, str]:
+ """Split a relative path into its subfolder path and file name."""
+ path = PurePosixPath(relative_path)
+ parent = str(path.parent)
+ return ("" if parent == "." else parent, path.name)
+
+
+def _read_file_contents(
+ hass: HomeAssistant, filenames: list[str]
+) -> list[tuple[str, bytes]]:
+ """Return the destination-relative path and file contents for each file."""
+ files = _expand_filenames(hass, filenames)
+ missing: list[str] = []
+ for full_path, _ in files:
+ if not hass.config.is_allowed_path(full_path):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="no_access_to_path",
+ translation_placeholders={"filename": full_path},
+ )
+ if not Path(full_path).exists():
+ missing.append(full_path)
if missing:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -71,20 +134,20 @@ def _read_file_contents(
},
)
results = []
- for filename in filenames:
- filename_path = Path(filename)
- file_size = filename_path.stat().st_size
+ for full_path, relative_path in files:
+ path = Path(full_path)
+ file_size = path.stat().st_size
if file_size > CONTENT_SIZE_LIMIT:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="file_too_large",
translation_placeholders={
- "filename": filename,
+ "filename": full_path,
"size": str(file_size),
"limit": str(CONTENT_SIZE_LIMIT),
},
)
- results.append((filename_path.name, filename_path.read_bytes()))
+ results.append((relative_path, path.read_bytes()))
return results
@@ -121,18 +184,33 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID]
)
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
+ # make sure the destination folders exist, preserving subfolder structure
+ folder_ids: dict[str, str] = {}
try:
- folder_id = (await client.get_approot()).id
+ base_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
+ base_folder_id = (await client.create_folder(base_folder_id, folder)).id
+ folder_ids[""] = base_folder_id
+
+ for relative_path, _ in file_results:
+ sub_folder, _ = _destination_parts(relative_path)
+ if sub_folder in folder_ids:
+ continue
+ parent_id = base_folder_id
+ accumulated = ""
+ for part in PurePosixPath(sub_folder).parts:
+ accumulated = f"{accumulated}/{part}" if accumulated else part
+ if accumulated not in folder_ids:
+ folder_ids[accumulated] = (
+ await client.create_folder(parent_id, part)
+ ).id
+ parent_id = folder_ids[accumulated]
except OneDriveException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -140,10 +218,12 @@ def async_setup_services(hass: HomeAssistant) -> None:
translation_placeholders={"message": str(err)},
) from err
- upload_tasks = [
- client.upload_file(folder_id, file_name, content)
- for file_name, content in file_results
- ]
+ upload_tasks = []
+ for relative_path, content in file_results:
+ sub_folder, name = _destination_parts(relative_path)
+ upload_tasks.append(
+ client.upload_file(folder_ids[sub_folder], name, content)
+ )
try:
upload_results = await asyncio.gather(*upload_tasks)
except OneDriveException as err:
diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json
index 5ba210929b00..11befd773654 100644
--- a/homeassistant/components/onedrive/strings.json
+++ b/homeassistant/components/onedrive/strings.json
@@ -117,6 +117,9 @@
"no_access_to_path": {
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
+ "no_files_match_pattern": {
+ "message": "No files match the following patterns: {patterns}"
+ },
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
@@ -179,7 +182,7 @@
"name": "Destination folder"
},
"filename": {
- "description": "One or more paths to files to upload.",
+ "description": "One or more paths to files to upload. Supports wildcards, for example `/config/www/*.jpg` to upload all JPG files in a folder, or `/config/www/**/*.jpg` to also include subfolders. Subfolders matched by a wildcard are recreated inside the destination folder. The characters `*`, `?` and `[` are treated as wildcards; to upload a file whose name contains one of these characters literally, make sure the file exists under that exact name.",
"example": "{example_image_path}",
"name": "Filenames"
}
diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json
index d29370d58d8b..c41c0e7de568 100644
--- a/homeassistant/components/onvif/manifest.json
+++ b/homeassistant/components/onvif/manifest.json
@@ -14,7 +14,7 @@
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": [
- "onvif-zeep-async==4.2.0",
+ "onvif-zeep-async==4.2.1",
"onvif_parsers==2.3.0",
"WSDiscovery==2.1.2"
]
diff --git a/homeassistant/components/open_meteo/coordinator.py b/homeassistant/components/open_meteo/coordinator.py
index e4834994a12b..138594cb204e 100644
--- a/homeassistant/components/open_meteo/coordinator.py
+++ b/homeassistant/components/open_meteo/coordinator.py
@@ -14,7 +14,7 @@ from open_meteo import (
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
+from homeassistant.const import CONF_ZONE, EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -49,8 +49,8 @@ class OpenMeteoDataUpdateCoordinator(DataUpdateCoordinator[Forecast]):
try:
return await self.open_meteo.forecast(
- latitude=zone.attributes[ATTR_LATITUDE],
- longitude=zone.attributes[ATTR_LONGITUDE],
+ latitude=zone.attributes[EntityStateAttribute.LATITUDE],
+ longitude=zone.attributes[EntityStateAttribute.LONGITUDE],
current_weather=True,
daily=[
DailyParameters.PRECIPITATION_SUM,
diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json
index 5be81a48a75f..1631b3f9df5b 100644
--- a/homeassistant/components/open_router/manifest.json
+++ b/homeassistant/components/open_router/manifest.json
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
- "requirements": ["openai==2.21.0", "python-open-router==0.3.3"]
+ "requirements": ["openai==2.45.0", "python-open-router==0.3.3"]
}
diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py
index f34f88c2cae7..77ccb98d9a35 100644
--- a/homeassistant/components/openai_conversation/__init__.py
+++ b/homeassistant/components/openai_conversation/__init__.py
@@ -36,7 +36,7 @@ from homeassistant.helpers import (
selector,
)
from homeassistant.helpers.httpx_client import get_async_client
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import (
CONF_CHAT_MODEL,
@@ -386,7 +386,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
# Device and entity registries will set the disabled_by flag to None
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
# config entry, but we want to set it to USER instead,
- device_disabled_by = device.disabled_by
+ device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
and not all_disabled
@@ -396,20 +396,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
disabled_by=device_disabled_by,
new_identifiers={(DOMAIN, subentry.subentry_id)},
- add_config_subentry_id=subentry.subentry_id,
- add_config_entry_id=parent_entry.entry_id,
+ new_config_entry_id=parent_entry.entry_id,
+ new_config_subentry_id=subentry.subentry_id,
)
- if parent_entry.entry_id != entry.entry_id:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- )
- else:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- remove_config_subentry_id=None,
- )
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py
index e2aa19b4ad23..05ed4fe18f5f 100644
--- a/homeassistant/components/openai_conversation/config_flow.py
+++ b/homeassistant/components/openai_conversation/config_flow.py
@@ -20,12 +20,11 @@ from homeassistant.config_entries import (
SubentryFlowResult,
)
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_API_KEY,
CONF_LLM_HASS_API,
CONF_NAME,
CONF_PROMPT,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
@@ -49,6 +48,7 @@ from .const import (
CONF_CODE_INTERPRETER,
CONF_IMAGE_MODEL,
CONF_MAX_TOKENS,
+ CONF_PRO_MODE,
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_RECOMMENDED,
@@ -78,6 +78,7 @@ from .const import (
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_IMAGE_MODEL,
RECOMMENDED_MAX_TOKENS,
+ RECOMMENDED_PRO_MODE,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
@@ -422,6 +423,18 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
elif CONF_REASONING_EFFORT in options:
options.pop(CONF_REASONING_EFFORT)
+ if model.startswith("gpt-5.6"):
+ step_schema.update(
+ {
+ vol.Optional(
+ CONF_PRO_MODE,
+ default=RECOMMENDED_PRO_MODE,
+ ): bool,
+ }
+ )
+ elif CONF_PRO_MODE in options:
+ options.pop(CONF_PRO_MODE)
+
if model.startswith("gpt-5"):
step_schema.update(
{
@@ -593,6 +606,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
return []
models_reasoning_map: dict[str | tuple[str, ...], list[str]] = {
+ "gpt-5.6": ["none", "low", "medium", "high", "xhigh", "max"],
("gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"): ["medium", "high", "xhigh"],
("gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"): [
"none",
@@ -654,8 +668,8 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
{
"role": "system",
"content": "Where are the following coordinates located: "
- f"({zone_home.attributes[ATTR_LATITUDE]},"
- f" {zone_home.attributes[ATTR_LONGITUDE]})?",
+ f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
+ f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
}
],
text={
diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py
index 5236a0d9f53a..6f76455b0c02 100644
--- a/homeassistant/components/openai_conversation/const.py
+++ b/homeassistant/components/openai_conversation/const.py
@@ -20,6 +20,7 @@ CONF_IMAGE_MODEL = "image_model"
CONF_CODE_INTERPRETER = "code_interpreter"
CONF_FILENAMES = "filenames"
CONF_MAX_TOKENS = "max_tokens"
+CONF_PRO_MODE = "pro_mode"
CONF_REASONING_EFFORT = "reasoning_effort"
CONF_REASONING_SUMMARY = "reasoning_summary"
CONF_RECOMMENDED = "recommended"
@@ -41,6 +42,7 @@ RECOMMENDED_CODE_INTERPRETER = False
RECOMMENDED_CHAT_MODEL = "gpt-4o-mini"
RECOMMENDED_IMAGE_MODEL = "gpt-image-2"
RECOMMENDED_MAX_TOKENS = 3000
+RECOMMENDED_PRO_MODE = False
RECOMMENDED_REASONING_EFFORT = "low"
RECOMMENDED_STORE_RESPONSES = False
RECOMMENDED_REASONING_SUMMARY = "auto"
diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py
index 5ac94beb19a5..5fa447e9b925 100644
--- a/homeassistant/components/openai_conversation/entity.py
+++ b/homeassistant/components/openai_conversation/entity.py
@@ -73,6 +73,7 @@ from .const import (
CONF_CODE_INTERPRETER,
CONF_IMAGE_MODEL,
CONF_MAX_TOKENS,
+ CONF_PRO_MODE,
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_SERVICE_TIER,
@@ -93,6 +94,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_IMAGE_MODEL,
RECOMMENDED_MAX_TOKENS,
+ RECOMMENDED_PRO_MODE,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
@@ -497,7 +499,7 @@ class OpenAIBaseLLMEntity(Entity):
entry_type=dr.DeviceEntryType.SERVICE,
)
- async def _async_handle_chat_log(
+ async def _async_handle_chat_log( # noqa: C901
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
@@ -528,11 +530,16 @@ class OpenAIBaseLLMEntity(Entity):
if not model_args["model"].startswith("gpt-5-pro")
else "high", # GPT-5 pro only supports reasoning.effort: high
}
+
reasoning_summary = options.get(
CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY
)
if reasoning_summary != "off":
reasoning["summary"] = reasoning_summary
+
+ if options.get(CONF_PRO_MODE, RECOMMENDED_PRO_MODE):
+ reasoning["mode"] = "pro"
+
model_args["reasoning"] = reasoning
model_args["include"] = ["reasoning.encrypted_content"]
diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json
index 7460bf938a7f..95fb8fc7d211 100644
--- a/homeassistant/components/openai_conversation/manifest.json
+++ b/homeassistant/components/openai_conversation/manifest.json
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
- "requirements": ["openai==2.21.0"]
+ "requirements": ["openai==2.45.0"]
}
diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json
index 03637baf4868..6b7d21ea44c2 100644
--- a/homeassistant/components/openai_conversation/strings.json
+++ b/homeassistant/components/openai_conversation/strings.json
@@ -71,6 +71,7 @@
"code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::code_interpreter%]",
"image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::image_model%]",
"inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::inline_citations%]",
+ "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::pro_mode%]",
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]",
"reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]",
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]",
@@ -82,6 +83,7 @@
"code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::code_interpreter%]",
"image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::image_model%]",
"inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::inline_citations%]",
+ "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::pro_mode%]",
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]",
"reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]",
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]",
@@ -138,6 +140,7 @@
"code_interpreter": "Enable code interpreter tool",
"image_model": "Image generation model",
"inline_citations": "Include links in web search results",
+ "pro_mode": "Pro mode",
"reasoning_effort": "Reasoning effort",
"reasoning_summary": "Reasoning summary",
"search_context_size": "Search context size",
@@ -149,6 +152,7 @@
"code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions",
"image_model": "The model to use when generating images",
"inline_citations": "If disabled, additional prompt is added to ask the model to not include source citations",
+ "pro_mode": "Perform more model work to improve reliability on difficult tasks and return a single final answer",
"reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt",
"reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model",
"search_context_size": "High level guidance for the amount of context window space to use for the search",
@@ -233,6 +237,7 @@
"options": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
+ "max": "Max",
"medium": "[%key:common::state::medium%]",
"minimal": "Minimal",
"none": "None",
diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py
index 3bbaabf3b0f7..323b2ae28867 100644
--- a/homeassistant/components/opower/sensor.py
+++ b/homeassistant/components/opower/sensor.py
@@ -287,9 +287,7 @@ async def async_setup_entry(
if entity_entry.config_entry_id != entry.entry_id:
continue
entity_registry.async_remove(entity_entry.entity_id)
- device_registry.async_update_device(
- device_entry.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(device_entry.id)
# Prune sensor tracking for accounts that are no longer present
if created_sensors:
diff --git a/homeassistant/components/oralb/icons.json b/homeassistant/components/oralb/icons.json
index 7f28dede4ae1..a3b464edc1d2 100644
--- a/homeassistant/components/oralb/icons.json
+++ b/homeassistant/components/oralb/icons.json
@@ -37,8 +37,7 @@
"sector_1": "mdi:circle-slice-2",
"sector_2": "mdi:circle-slice-4",
"sector_3": "mdi:circle-slice-6",
- "sector_4": "mdi:circle-slice-8",
- "success": "mdi:check-circle-outline"
+ "sector_4": "mdi:circle-slice-8"
}
},
"toothbrush_state": {
diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json
index a15ea81e9067..7bc928e46e5b 100644
--- a/homeassistant/components/oralb/manifest.json
+++ b/homeassistant/components/oralb/manifest.json
@@ -13,5 +13,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["oralb_ble"],
- "requirements": ["oralb-ble==1.1.0"]
+ "requirements": ["oralb-ble==1.1.1"]
}
diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py
index 286defcf35f8..28fb2be64c74 100644
--- a/homeassistant/components/oralb/sensor.py
+++ b/homeassistant/components/oralb/sensor.py
@@ -3,13 +3,7 @@
from typing import override
from oralb_ble import OralBSensor, SensorUpdate
-from oralb_ble.parser import (
- IO_SERIES_MODES,
- PRESSURE,
- SECTOR_MAP,
- SMART_SERIES_MODES,
- STATES,
-)
+from oralb_ble.parser import IO_SERIES_MODES, PRESSURE, SMART_SERIES_MODES, STATES
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
@@ -46,7 +40,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
key=OralBSensor.SECTOR,
translation_key="sector",
entity_category=EntityCategory.DIAGNOSTIC,
- options=[v.replace(" ", "_") for v in set(SECTOR_MAP.values()) | {"no_sector"}],
+ options=["no_sector", *(f"sector_{sector}" for sector in range(1, 8))],
device_class=SensorDeviceClass.ENUM,
),
OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription(
diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json
index 2aa29d12f13e..de7e62978a48 100644
--- a/homeassistant/components/oralb/strings.json
+++ b/homeassistant/components/oralb/strings.json
@@ -60,7 +60,9 @@
"sector_2": "Sector 2",
"sector_3": "Sector 3",
"sector_4": "Sector 4",
- "success": "Success"
+ "sector_5": "Sector 5",
+ "sector_6": "Sector 6",
+ "sector_7": "Sector 7"
}
},
"sector_timer": {
diff --git a/homeassistant/components/overkiz/application_credentials.py b/homeassistant/components/overkiz/application_credentials.py
index a3c01abc4a7b..c537edcf1b4c 100644
--- a/homeassistant/components/overkiz/application_credentials.py
+++ b/homeassistant/components/overkiz/application_credentials.py
@@ -43,6 +43,6 @@ class OverkizOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
otherwise drop it.
"""
return super().extra_authorize_data | {
- "scope": REXEL_OAUTH_SCOPE,
+ "scope": f"{REXEL_OAUTH_SCOPE} offline_access",
"p": REXEL_OAUTH_POLICY,
}
diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py
index f614d9555670..957767709dd8 100644
--- a/homeassistant/components/overkiz/binary_sensor.py
+++ b/homeassistant/components/overkiz/binary_sensor.py
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import cast, override
-from pyoverkiz.enums import OverkizCommandParam, OverkizState
+from pyoverkiz.enums import OverkizCommandParam, OverkizState, UIClass, UIWidget
from pyoverkiz.types import StateType as OverkizStateType
from homeassistant.components.binary_sensor import (
@@ -26,6 +26,10 @@ class OverkizBinarySensorDescription(BinarySensorEntityDescription):
value_fn: Callable[[OverkizStateType], bool]
+ # Restrict this entity to the listed device types (UIWidget/UIClass).
+ # When omitted, the sensor applies to any device exposing the state.
+ device_types: list[UIWidget | UIClass] | None = None
+
BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [
# RainSensor/RainSensor
@@ -135,6 +139,23 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [
)
),
),
+ # ContactSensor/IntrusionEventSensor
+ # (io:SomfyWindowStateSensor, io:SomfySlidingWindowStateSensor)
+ OverkizBinarySensorDescription(
+ key=OverkizState.CORE_OPEN_CLOSED,
+ device_class=BinarySensorDeviceClass.WINDOW,
+ value_fn=lambda state: state == OverkizCommandParam.OPEN,
+ # core:OpenClosedState is also exposed by all cover devices,
+ # restrict this to ContactSensor devices (e.g. the Somfy IntelliTAG)
+ device_types=[UIClass.CONTACT_SENSOR],
+ ),
+ # ContactSensor/IntrusionEventSensor (io:SomfyWindowStateSensor)
+ OverkizBinarySensorDescription(
+ key=OverkizState.CORE_TILTED,
+ name="Tilt",
+ icon="mdi:angle-acute",
+ value_fn=bool,
+ ),
]
SUPPORTED_STATES = {
@@ -166,6 +187,11 @@ async def async_setup_entry(
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state))
+ and (
+ description.device_types is None
+ or device.widget in description.device_types
+ or device.ui_class in description.device_types
+ )
)
async_add_entities(entities)
diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py
index 4f56034d03c0..e68c9d95b68a 100644
--- a/homeassistant/components/overkiz/climate/__init__.py
+++ b/homeassistant/components/overkiz/climate/__init__.py
@@ -57,6 +57,9 @@ WIDGET_TO_CLIMATE_ENTITY = {
UIWidget.EVO_HOME_CONTROLLER: EvoHomeController,
UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface,
UIWidget.SOMFY_THERMOSTAT: SomfyThermostat,
+ UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: (
+ ValveHeatingTemperatureInterface
+ ),
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface,
UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: AtlanticPassAPCHeatPumpMainComponent,
}
diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
index 099eb00ba0bb..8b813372fd35 100644
--- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
+++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
@@ -105,9 +105,11 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
states = self.device.states
- if (state := states[OverkizState.CORE_OPERATING_MODE]) and state.value_as_str:
+ if (
+ state := states.get(OverkizState.CORE_OPERATING_MODE)
+ ) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
- if (state := states[OverkizState.CORE_ON_OFF]) and state.value_as_str:
+ if (state := states.get(OverkizState.CORE_ON_OFF)) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
return HVACMode.OFF
@@ -123,7 +125,9 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
def hvac_action(self) -> HVACAction:
"""Return the current running hvac operation ie. heating, idle, off."""
states = self.device.states
- if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str:
+ if (
+ state := states.get(OverkizState.CORE_REGULATION_MODE)
+ ) and state.value_as_str:
return OVERKIZ_TO_HVAC_ACTION[state.value_as_str]
return HVACAction.OFF
@@ -135,12 +139,12 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
states = self.device.states
if (
- state := states[OverkizState.IO_TARGET_HEATING_LEVEL]
+ state := states.get(OverkizState.IO_TARGET_HEATING_LEVEL)
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
if (
- operating_mode := states[OverkizState.CORE_OPERATING_MODE]
+ operating_mode := states.get(OverkizState.CORE_OPERATING_MODE)
) and operating_mode.value_as_str == OverkizCommandParam.EXTERNAL:
return PRESET_EXTERNAL
return None
diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py
index c7069fe4ba08..21a4db0e12dc 100644
--- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py
+++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py
@@ -47,6 +47,14 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
+ # Each zone is its own device; zone 1 is the default, zone 2 overrides below.
+ _auto_manu_mode_state = OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1
+ _room_temperature_state = OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1
+ _thermostat_setting_state = OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1
+ _set_thermostat_setting_command = (
+ OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1
+ )
+
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
) -> None:
@@ -56,12 +64,24 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
if self._attr_device_info:
self._attr_device_info["manufacturer"] = "Hitachi"
+ if "Zone2" in self.device.controllable_name:
+ self._auto_manu_mode_state = OverkizState.MODBUS_AUTO_MANU_MODE_ZONE2
+ self._room_temperature_state = (
+ OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE2
+ )
+ self._thermostat_setting_state = (
+ OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE2
+ )
+ self._set_thermostat_setting_command = (
+ OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_2
+ )
+
@property
@override
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
- state := self.device.states.get(OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1)
+ state := self.device.states.get(self._auto_manu_mode_state)
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
@@ -96,9 +116,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@override
def current_temperature(self) -> float | None:
"""Return the current temperature."""
- current_temperature = self.device.states.get(
- OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1
- )
+ current_temperature = self.device.states.get(self._room_temperature_state)
if current_temperature:
return current_temperature.value_as_float
@@ -109,9 +127,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@override
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
- target_temperature = self.device.states.get(
- OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1
- )
+ target_temperature = self.device.states.get(self._thermostat_setting_state)
if target_temperature:
return target_temperature.value_as_float
@@ -124,5 +140,5 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
temperature = cast(float, kwargs.get(ATTR_TEMPERATURE))
await self.executor.async_execute_command(
- OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature)
+ self._set_thermostat_setting_command, float(temperature)
)
diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py
index ec3fcd218b8f..5793364ef955 100644
--- a/homeassistant/components/overkiz/config_flow.py
+++ b/homeassistant/components/overkiz/config_flow.py
@@ -11,7 +11,11 @@ from pyoverkiz.auth.credentials import (
UsernamePasswordCredentials,
)
from pyoverkiz.client import GatewayCandidate, OverkizClient
-from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS
+from pyoverkiz.const import (
+ REXEL_OAUTH_CLIENT_ID,
+ SERVERS_WITH_LOCAL_API,
+ SUPPORTED_SERVERS,
+)
from pyoverkiz.enums import APIType, Server
from pyoverkiz.exceptions import (
ApplicationNotAllowedError,
@@ -28,6 +32,10 @@ from pyoverkiz.obfuscate import obfuscate_id
from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway
import voluptuous as vol
+from homeassistant.components.application_credentials import (
+ ClientCredential,
+ async_import_client_credential,
+)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
@@ -179,6 +187,18 @@ class OverkizConfigFlow(
description_placeholders={"local_api_docs": LOCAL_API_DOCS_URL},
)
+ @override
+ async def async_step_pick_implementation(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Start the Rexel OAuth2 flow, re-importing the credential if removed."""
+ await async_import_client_credential(
+ self.hass,
+ DOMAIN,
+ ClientCredential(REXEL_OAUTH_CLIENT_ID, "", name="Rexel"),
+ )
+ return await super().async_step_pick_implementation(user_input)
+
async def async_step_cloud(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py
index b0cbe6f9c8a8..6748b59fa545 100644
--- a/homeassistant/components/overkiz/const.py
+++ b/homeassistant/components/overkiz/const.py
@@ -119,6 +119,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH,
UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH,
+ UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE,
UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE,
}
diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py
index 818baaada0fb..34397ff794e2 100644
--- a/homeassistant/components/overkiz/cover.py
+++ b/homeassistant/components/overkiz/cover.py
@@ -88,6 +88,17 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
invert_position=False,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
),
+ # Needs override to omit is_closed_state, since OpenClosedState is unreliable
+ # uiClass is RollerShutter
+ OverkizCoverDescription(
+ key=UIWidget.POSITIONABLE_ROLLER_SHUTTER_UNO,
+ device_class=CoverDeviceClass.SHUTTER,
+ current_position_state=OverkizState.CORE_CLOSURE,
+ set_position_command=OverkizCommand.SET_CLOSURE,
+ open_command=OverkizCommand.OPEN,
+ close_command=OverkizCommand.CLOSE,
+ stop_command=OverkizCommand.STOP,
+ ),
# Needs override to support lower/upper position control
# uiClass is RollerShutter
OverkizCoverDescription(
@@ -343,8 +354,8 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
# uiClass is Generic (not mapped to cover as this is a Generic device class)
OverkizCoverDescription(
key=UIWidget.RTS_GENERIC,
- open_command=OverkizCommand.OPEN,
- close_command=OverkizCommand.CLOSE,
+ open_command=OverkizCommand.UP,
+ close_command=OverkizCommand.DOWN,
stop_command=OverkizCommand.STOP,
),
##
diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json
index a4167e6a9f5d..0468340432a1 100644
--- a/homeassistant/components/overkiz/manifest.json
+++ b/homeassistant/components/overkiz/manifest.json
@@ -14,7 +14,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"],
- "requirements": ["pyoverkiz[nexity]==2.0.3"],
+ "requirements": ["pyoverkiz[nexity]==2.1.0"],
"zeroconf": [
{
"name": "gateway*",
diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py
index 72198368f18b..81e7de6ae857 100644
--- a/homeassistant/components/overkiz/number.py
+++ b/homeassistant/components/overkiz/number.py
@@ -2,7 +2,7 @@
import asyncio
from collections.abc import Awaitable, Callable
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from typing import cast, override
from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
@@ -11,6 +11,7 @@ from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
+ NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
@@ -19,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .const import IGNORED_OVERKIZ_DEVICES
from .coordinator import OverkizDataUpdateCoordinator
+from .cover import SUPPORTED_DEVICES as SUPPORTED_COVER_DEVICES
from .entity import OverkizDescriptiveEntity
BOOST_MODE_DURATION_DELAY = 1
@@ -185,6 +187,36 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
),
+ # AtlanticElectricalTowelDryer - boost mode duration in minutes
+ OverkizNumberDescription(
+ key=OverkizState.IO_BOOST_DURATION_USER_PARAMETER,
+ name="Boost mode duration",
+ icon="mdi:radiator",
+ command=OverkizCommand.SET_TOWEL_DRYER_BOOST_MODE_DURATION,
+ native_min_value=0,
+ native_max_value=60,
+ native_step=1,
+ mode=NumberMode.BOX,
+ max_value_state_name=OverkizState.IO_BOOST_DURATION_MAX,
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
+ # AtlanticElectricalTowelDryer - drying duration in minutes (0 - 120)
+ OverkizNumberDescription(
+ key=OverkizState.IO_DRYING_DURATION_USER_PARAMETER,
+ name="Drying duration",
+ icon="mdi:tumble-dryer",
+ command=OverkizCommand.SET_DRYING_DURATION,
+ native_min_value=0,
+ native_max_value=120,
+ native_step=1,
+ mode=NumberMode.BOX,
+ max_value_state_name=OverkizState.IO_DRYING_DURATION_MAX,
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
]
SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCRIPTIONS}
@@ -206,15 +238,31 @@ async def async_setup_entry(
):
continue
- entities.extend(
- OverkizNumber(
- device.device_url,
- data.coordinator,
- description,
+ for state in device.definition.states:
+ if not (description := SUPPORTED_STATES.get(state)):
+ continue
+
+ if not device.supports_command(description.command):
+ continue
+
+ # Mirror the cover's position inversion.
+ if description.key == OverkizState.CORE_MEMORIZED_1_POSITION and (
+ cover_description := (
+ SUPPORTED_COVER_DEVICES.get(device.widget)
+ or SUPPORTED_COVER_DEVICES.get(device.ui_class)
+ )
+ ):
+ description = replace(
+ description, inverted=cover_description.invert_position
+ )
+
+ entities.append(
+ OverkizNumber(
+ device.device_url,
+ data.coordinator,
+ description,
+ )
)
- for state in device.definition.states
- if (description := SUPPORTED_STATES.get(state))
- )
async_add_entities(entities)
diff --git a/homeassistant/components/overkiz/quality_scale.yaml b/homeassistant/components/overkiz/quality_scale.yaml
new file mode 100644
index 000000000000..607b2ee9373c
--- /dev/null
+++ b/homeassistant/components/overkiz/quality_scale.yaml
@@ -0,0 +1,68 @@
+rules:
+ # Bronze
+ config-flow: done
+ brands: done
+ dependency-transparency: done
+ common-modules: done
+ has-entity-name: done
+ action-setup: done
+ appropriate-polling: done
+ test-before-configure: done
+ entity-event-setup: done
+ unique-config-entry: done
+ entity-unique-id: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ test-before-setup: done
+ docs-high-level-description: done
+ config-flow-test-coverage: todo
+ docs-actions: done
+ docs-conditions:
+ status: exempt
+ comment: This integration does not have any conditions.
+ docs-triggers:
+ status: exempt
+ comment: This integration does not have any triggers.
+ runtime-data: done
+
+ # Silver
+ log-when-unavailable: done
+ config-entry-unloading: done
+ reauthentication-flow: done
+ action-exceptions: done
+ docs-installation-parameters: done
+ integration-owner: done
+ parallel-updates: todo
+ test-coverage: todo
+ docs-configuration-parameters:
+ status: exempt
+ comment: The integration does not provide configuration parameters.
+ entity-unavailable: done
+
+ # Gold
+ docs-examples: todo
+ discovery-update-info: todo
+ entity-device-class: done
+ entity-translations: todo
+ docs-data-update: done
+ entity-disabled-by-default: done
+ discovery: done
+ exception-translations: todo
+ devices: done
+ docs-supported-devices: done
+ icon-translations: todo
+ docs-known-limitations: done
+ stale-devices: todo
+ docs-supported-functions: todo
+ repair-issues: todo
+ reconfiguration-flow: todo
+ entity-category: done
+ dynamic-devices: todo
+ docs-troubleshooting: todo
+ diagnostics: done
+ docs-use-cases: todo
+
+ # Platinum
+ async-dependency: done
+ strict-typing: done
+ inject-websession: done
diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py
index b955d2a50a40..a48ac7669b41 100644
--- a/homeassistant/components/overseerr/const.py
+++ b/homeassistant/components/overseerr/const.py
@@ -9,9 +9,14 @@ LOGGER = logging.getLogger(__package__)
REQUESTS = "requests"
+ATTR_MEDIA_TYPE = "media_type"
+ATTR_QUERY = "query"
+ATTR_REQUESTED_BY = "requested_by"
+ATTR_SEASONS = "seasons"
ATTR_STATUS = "status"
ATTR_SORT_ORDER = "sort_order"
-ATTR_REQUESTED_BY = "requested_by"
+ATTR_MEDIA_ID = "media_id"
+
EVENT_KEY = f"{DOMAIN}_event"
diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json
index 9b63943f8989..290aa0a976dc 100644
--- a/homeassistant/components/overseerr/icons.json
+++ b/homeassistant/components/overseerr/icons.json
@@ -32,6 +32,12 @@
"services": {
"get_requests": {
"service": "mdi:multimedia"
+ },
+ "request_media": {
+ "service": "mdi:download"
+ },
+ "search_media": {
+ "service": "mdi:magnify"
}
}
}
diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py
index 5354102472ca..9405b21ea6dd 100644
--- a/homeassistant/components/overseerr/services.py
+++ b/homeassistant/components/overseerr/services.py
@@ -1,7 +1,8 @@
"""Define services for the Overseerr integration."""
+import ast
from dataclasses import asdict
-from typing import Any, cast
+from typing import Any, Literal, cast
from python_overseerr import OverseerrClient, OverseerrConnectionError
import voluptuous as vol
@@ -18,10 +19,23 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import service
from homeassistant.util.json import JsonValueType
-from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER
+from .const import (
+ ATTR_MEDIA_ID,
+ ATTR_MEDIA_TYPE,
+ ATTR_QUERY,
+ ATTR_REQUESTED_BY,
+ ATTR_SEASONS,
+ ATTR_SORT_ORDER,
+ ATTR_STATUS,
+ DOMAIN,
+ LOGGER,
+)
from .coordinator import OverseerrConfigEntry
SERVICE_GET_REQUESTS = "get_requests"
+SERVICE_SEARCH_MEDIA = "search_media"
+SERVICE_REQUEST_MEDIA = "request_media"
+
SERVICE_GET_REQUESTS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
@@ -33,6 +47,29 @@ SERVICE_GET_REQUESTS_SCHEMA = vol.Schema(
}
)
+SERVICE_SEARCH_MEDIA_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY_ID): str,
+ vol.Required(ATTR_QUERY): str,
+ }
+)
+
+SERVICE_REQUEST_MEDIA_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY_ID): str,
+ vol.Required(ATTR_MEDIA_TYPE): vol.In(["movie", "tv"]),
+ vol.Required(ATTR_MEDIA_ID): vol.All(
+ vol.Coerce(int),
+ vol.Range(min=1),
+ ),
+ vol.Optional(ATTR_SEASONS): vol.Any(
+ vol.Coerce(int),
+ [vol.Coerce(int)],
+ str,
+ ),
+ }
+)
+
async def _get_media(
client: OverseerrClient, media_type: str, identifier: int
@@ -52,7 +89,7 @@ async def _get_media(
async def _async_get_requests(call: ServiceCall) -> ServiceResponse:
- """Get requests made to Overseerr."""
+ """Get requests made to Seerr."""
entry: OverseerrConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
@@ -92,9 +129,79 @@ async def _async_get_requests(call: ServiceCall) -> ServiceResponse:
return {"requests": cast(list[JsonValueType], result)}
+async def _async_search_media(call: ServiceCall) -> ServiceResponse:
+ """Search for media in Seerr."""
+ entry: OverseerrConfigEntry = service.async_get_config_entry(
+ call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
+ )
+ client = entry.runtime_data.client
+ query = call.data[ATTR_QUERY]
+
+ LOGGER.debug("Searching for '%s'", query)
+ try:
+ search_results = await client.search(query)
+ except OverseerrConnectionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
+
+ return {
+ "results": cast(
+ list[JsonValueType], [asdict(result) for result in search_results]
+ )
+ }
+
+
+async def _async_request_media(call: ServiceCall) -> ServiceResponse:
+ """Request media in Seerr."""
+ entry: OverseerrConfigEntry = service.async_get_config_entry(
+ call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
+ )
+ client = entry.runtime_data.client
+ media_type = call.data[ATTR_MEDIA_TYPE]
+ media_id = call.data[ATTR_MEDIA_ID]
+ seasons = parse_seasons_input(call.data.get(ATTR_SEASONS))
+
+ LOGGER.debug(
+ "Requesting %s with media ID %s (seasons: %s)",
+ media_type,
+ media_id,
+ seasons or "none",
+ )
+ try:
+ # We can always pass in the seasons, they will be ignored if the media type isn't TV
+ request = await client.create_request(media_type, media_id, seasons)
+ except OverseerrConnectionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
+
+ return {"request": cast(JsonValueType, asdict(request))}
+
+
+def parse_seasons_input(seasons_input: Any | None) -> Literal["all"] | list[int]:
+ """Parse all possible inputs to "all" or a list of integers."""
+ seasons_str = str(seasons_input).strip()
+ if seasons_input is None or seasons_str in ("", "all"):
+ return "all"
+
+ try:
+ parsed = ast.literal_eval(seasons_str)
+ if isinstance(parsed, int):
+ return [parsed]
+ return [int(season) for season in parsed]
+ except ValueError, SyntaxError, TypeError:
+ LOGGER.error("Unable to cast input to a list '%s'", seasons_input)
+ return "all"
+
+
@callback
def async_setup_services(hass: HomeAssistant) -> None:
- """Set up the services for the Overseerr integration."""
+ """Set up the services for the Seerr integration."""
hass.services.async_register(
DOMAIN,
@@ -103,3 +210,19 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=SERVICE_GET_REQUESTS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SEARCH_MEDIA,
+ _async_search_media,
+ schema=SERVICE_SEARCH_MEDIA_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_REQUEST_MEDIA,
+ _async_request_media,
+ schema=SERVICE_REQUEST_MEDIA_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/overseerr/services.yaml b/homeassistant/components/overseerr/services.yaml
index c7593fc5aee1..3fcf49ba8f5d 100644
--- a/homeassistant/components/overseerr/services.yaml
+++ b/homeassistant/components/overseerr/services.yaml
@@ -28,3 +28,40 @@ get_requests:
number:
min: 0
mode: box
+
+search_media:
+ fields:
+ config_entry_id:
+ required: true
+ selector:
+ config_entry:
+ integration: overseerr
+ query:
+ required: true
+ selector:
+ text:
+
+request_media:
+ fields:
+ config_entry_id:
+ required: true
+ selector:
+ config_entry:
+ integration: overseerr
+ media_type:
+ required: true
+ selector:
+ select:
+ options:
+ - movie
+ - tv
+ translation_key: request_media_type
+ media_id:
+ required: true
+ selector:
+ number:
+ min: 1
+ mode: box
+ seasons:
+ selector:
+ text:
diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json
index 9ddfc6929f6d..aa139f6cf919 100644
--- a/homeassistant/components/overseerr/strings.json
+++ b/homeassistant/components/overseerr/strings.json
@@ -118,6 +118,12 @@
}
},
"selector": {
+ "request_media_type": {
+ "options": {
+ "movie": "Movie",
+ "tv": "TV"
+ }
+ },
"request_sort_order": {
"options": {
"added": "Added",
@@ -157,6 +163,42 @@
}
},
"name": "Get requests"
+ },
+ "request_media": {
+ "description": "Creates a media request in Seerr.",
+ "fields": {
+ "config_entry_id": {
+ "description": "The Seerr instance to create the request on.",
+ "name": "Seerr instance"
+ },
+ "media_id": {
+ "description": "The TMDB ID or TVDB ID of the media to request.",
+ "name": "Media ID"
+ },
+ "media_type": {
+ "description": "Type of media to request.",
+ "name": "Media type"
+ },
+ "seasons": {
+ "description": "For TV requests: seasons to request. Optional list of integers (e.g., [1, 2, 4]). If omitted, all seasons will be requested.",
+ "name": "Seasons"
+ }
+ },
+ "name": "Request media"
+ },
+ "search_media": {
+ "description": "Searches for media in Seerr.",
+ "fields": {
+ "config_entry_id": {
+ "description": "The Seerr instance to search.",
+ "name": "Seerr instance"
+ },
+ "query": {
+ "description": "The search query.",
+ "name": "Query"
+ }
+ },
+ "name": "Search media"
}
}
}
diff --git a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json
index f2393ec1ade1..93feba804811 100644
--- a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json
+++ b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
- "requirements": ["openai==2.21.0"]
+ "requirements": ["openai==2.45.0"]
}
diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py
index d76d07a8b82e..6f3ba4b48511 100644
--- a/homeassistant/components/owntracks/__init__.py
+++ b/homeassistant/components/owntracks/__init__.py
@@ -10,14 +10,9 @@ from aiohttp import web
import voluptuous as vol
from homeassistant.components import cloud, mqtt, webhook
+from homeassistant.components.device_tracker import TrackerEntityStateAttribute
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_GPS_ACCURACY,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
- CONF_WEBHOOK_ID,
- Platform,
-)
+from homeassistant.const import CONF_WEBHOOK_ID, EntityStateAttribute, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
@@ -194,13 +189,14 @@ async def handle_webhook(
response = [
{
"_type": "location",
- "lat": person.attributes["latitude"],
- "lon": person.attributes["longitude"],
+ "lat": person.attributes[EntityStateAttribute.LATITUDE],
+ "lon": person.attributes[EntityStateAttribute.LONGITUDE],
"tid": "".join(p[0] for p in person.name.split(" ")[:2]),
"tst": int(person.last_updated.timestamp()),
}
for person in hass.states.async_all("person")
- if "latitude" in person.attributes and "longitude" in person.attributes
+ if EntityStateAttribute.LATITUDE in person.attributes
+ and EntityStateAttribute.LONGITUDE in person.attributes
]
if message["_type"] == "encrypted" and context.secret:
@@ -297,9 +293,11 @@ class OwnTracksContext:
device_tracker_state = hass.states.get(f"device_tracker.{dev_id}")
if device_tracker_state is not None:
- acc = device_tracker_state.attributes.get(ATTR_GPS_ACCURACY)
- lat = device_tracker_state.attributes.get(ATTR_LATITUDE)
- lon = device_tracker_state.attributes.get(ATTR_LONGITUDE)
+ acc = device_tracker_state.attributes.get(
+ TrackerEntityStateAttribute.GPS_ACCURACY
+ )
+ lat = device_tracker_state.attributes.get(EntityStateAttribute.LATITUDE)
+ lon = device_tracker_state.attributes.get(EntityStateAttribute.LONGITUDE)
if lat is not None and lon is not None:
kwargs["gps"] = (lat, lon)
diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py
index 3711b60f9cfe..477bf74c4cfa 100644
--- a/homeassistant/components/owntracks/device_tracker.py
+++ b/homeassistant/components/owntracks/device_tracker.py
@@ -4,18 +4,14 @@
from typing import Any, override
from homeassistant.components.device_tracker import (
- ATTR_SOURCE_TYPE,
DOMAIN as DEVICE_TRACKER_DOMAIN,
+ DeviceTrackerEntityStateAttribute,
SourceType,
TrackerEntity,
+ TrackerEntityStateAttribute,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_BATTERY_LEVEL,
- ATTR_GPS_ACCURACY,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
-)
+from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -177,10 +173,13 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity):
self._data = {
"host_name": state.name,
- "gps": (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)),
- "gps_accuracy": attr.get(ATTR_GPS_ACCURACY),
+ "gps": (
+ attr.get(EntityStateAttribute.LATITUDE),
+ attr.get(EntityStateAttribute.LONGITUDE),
+ ),
+ "gps_accuracy": attr.get(TrackerEntityStateAttribute.GPS_ACCURACY),
"battery": attr.get(ATTR_BATTERY_LEVEL),
- "source_type": attr.get(ATTR_SOURCE_TYPE),
+ "source_type": attr.get(DeviceTrackerEntityStateAttribute.SOURCE_TYPE),
"attributes": attributes,
}
diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py
index b59ec84749d8..668b13515481 100644
--- a/homeassistant/components/owntracks/messages.py
+++ b/homeassistant/components/owntracks/messages.py
@@ -8,7 +8,8 @@ from nacl.secret import SecretBox
from homeassistant.components import zone as zone_comp
from homeassistant.components.device_tracker import SourceType
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME
+from homeassistant.components.zone import ZoneEntityStateAttribute
+from homeassistant.const import STATE_HOME, EntityStateAttribute
from homeassistant.util import decorator, dt as dt_util, slugify
from .const import (
@@ -108,10 +109,10 @@ def _set_gps_from_zone(kwargs, location, zone):
"""
if zone is not None:
kwargs["gps"] = (
- zone.attributes[ATTR_LATITUDE],
- zone.attributes[ATTR_LONGITUDE],
+ zone.attributes[EntityStateAttribute.LATITUDE],
+ zone.attributes[EntityStateAttribute.LONGITUDE],
)
- kwargs["gps_accuracy"] = zone.attributes["radius"]
+ kwargs["gps_accuracy"] = zone.attributes[ZoneEntityStateAttribute.RADIUS]
kwargs["location_name"] = location
return kwargs
diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py
index e88e3f885cc4..4fe461f27e5b 100644
--- a/homeassistant/components/pegel_online/sensor.py
+++ b/homeassistant/components/pegel_online/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -131,8 +131,8 @@ class PegelOnlineSensor(PegelOnlineEntity, SensorEntity):
if self.station.latitude and self.station.longitude:
self._attr_extra_state_attributes.update(
{
- ATTR_LATITUDE: self.station.latitude,
- ATTR_LONGITUDE: self.station.longitude,
+ EntityStateAttribute.LATITUDE: self.station.latitude,
+ EntityStateAttribute.LONGITUDE: self.station.longitude,
}
)
diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py
index ff3127d75a8e..898f43277400 100644
--- a/homeassistant/components/permobil/__init__.py
+++ b/homeassistant/components/permobil/__init__.py
@@ -1,59 +1,37 @@
"""The MyPermobil integration."""
-import logging
-
-from mypermobil import MyPermobil, MyPermobilClientException
-
-from homeassistant.const import (
- CONF_CODE,
- CONF_EMAIL,
- CONF_REGION,
- CONF_TOKEN,
- CONF_TTL,
- Platform,
-)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import issue_registry as ir
-from .const import APPLICATION
-from .coordinator import MyPermobilCoordinator, PermobilConfigEntry
-
-PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
-
-_LOGGER = logging.getLogger(__name__)
+DOMAIN = "permobil"
-async def async_setup_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool:
- """Set up MyPermobil from a config entry."""
-
- # create the API object from the config and save it in hass
- session = async_get_clientsession(hass)
- p_api = MyPermobil(
- application=APPLICATION,
- session=session,
- email=entry.data[CONF_EMAIL],
- region=entry.data[CONF_REGION],
- code=entry.data[CONF_CODE],
- token=entry.data[CONF_TOKEN],
- expiration_date=entry.data[CONF_TTL],
+async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
+ """Set up config entry."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ DOMAIN,
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="integration_removed",
+ translation_placeholders={
+ "entries": "/config/integrations/integration/permobil",
+ },
)
- try:
- p_api.self_authenticate()
- except MyPermobilClientException as err:
- _LOGGER.error("Error authenticating %s", err)
- raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err
-
- # create the coordinator with the API object
- coordinator = MyPermobilCoordinator(hass, entry, p_api)
- 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: PermobilConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ 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/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py
deleted file mode 100644
index 2f9f04251047..000000000000
--- a/homeassistant/components/permobil/binary_sensor.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""Platform for binary sensor integration."""
-
-from collections.abc import Callable
-from dataclasses import dataclass
-from typing import Any, override
-
-from mypermobil import BATTERY_CHARGING
-
-from homeassistant.components.binary_sensor import (
- BinarySensorEntity,
- BinarySensorEntityDescription,
-)
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-
-from .coordinator import PermobilConfigEntry
-from .entity import PermobilEntity
-
-
-@dataclass(frozen=True, kw_only=True)
-class PermobilBinarySensorEntityDescription(BinarySensorEntityDescription):
- """Describes Permobil binary sensor entity."""
-
- is_on_fn: Callable[[Any], bool]
- available_fn: Callable[[Any], bool]
-
-
-BINARY_SENSOR_DESCRIPTIONS: tuple[PermobilBinarySensorEntityDescription, ...] = (
- PermobilBinarySensorEntityDescription(
- is_on_fn=lambda data: data.battery[BATTERY_CHARGING[0]],
- available_fn=lambda data: BATTERY_CHARGING[0] in data.battery,
- key="is_charging",
- translation_key="is_charging",
- ),
-)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: PermobilConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Create and setup the binary sensor."""
-
- coordinator = config_entry.runtime_data
-
- async_add_entities(
- PermobilbinarySensor(coordinator=coordinator, description=description)
- for description in BINARY_SENSOR_DESCRIPTIONS
- )
-
-
-class PermobilbinarySensor(PermobilEntity, BinarySensorEntity):
- """Representation of a Binary Sensor."""
-
- entity_description: PermobilBinarySensorEntityDescription
-
- @property
- @override
- def is_on(self) -> bool:
- """Return True if the wheelchair is charging."""
- return self.entity_description.is_on_fn(self.coordinator.data)
-
- @property
- @override
- def available(self) -> bool:
- """Return True if the sensor has value."""
- return super().available and self.entity_description.available_fn(
- self.coordinator.data
- )
diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py
index 5b05a8eabbe2..b1711b894697 100644
--- a/homeassistant/components/permobil/config_flow.py
+++ b/homeassistant/components/permobil/config_flow.py
@@ -1,184 +1,11 @@
-"""Config flow for MyPermobil integration."""
+"""Config flow to configure Permobil integration."""
-from collections.abc import Mapping
-import logging
-from typing import Any, override
+from homeassistant.config_entries import ConfigFlow
-from mypermobil import (
- MyPermobil,
- MyPermobilAPIException,
- MyPermobilClientException,
- MyPermobilEulaException,
-)
-import voluptuous as vol
-
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL
-from homeassistant.core import HomeAssistant, async_get_hass
-from homeassistant.helpers import config_validation as cv, selector
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.selector import (
- TextSelector,
- TextSelectorConfig,
- TextSelectorType,
-)
-
-from .const import APPLICATION, DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
-
-GET_EMAIL_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_EMAIL): TextSelector(
- TextSelectorConfig(type=TextSelectorType.EMAIL)
- ),
- }
-)
-
-GET_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_CODE): cv.string})
+from . import DOMAIN
class PermobilConfigFlow(ConfigFlow, domain=DOMAIN):
- """Permobil config flow."""
+ """Permobil integration config flow."""
VERSION = 1
- region_names: dict[str, str] = {}
- data: dict[str, str] = {}
-
- def __init__(self) -> None:
- """Initialize flow."""
- hass: HomeAssistant = async_get_hass()
- session = async_get_clientsession(hass)
- self.p_api = MyPermobil(APPLICATION, session=session)
-
- @override
- async def async_step_user(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Invoke when a user initiates a flow via the user interface."""
- errors: dict[str, str] = {}
-
- if user_input:
- try:
- self.p_api.set_email(user_input[CONF_EMAIL])
- except MyPermobilClientException:
- _LOGGER.exception("Error validating email")
- errors["base"] = "invalid_email"
-
- self.data.update(user_input)
-
- await self.async_set_unique_id(self.data[CONF_EMAIL])
- self._abort_if_unique_id_configured()
-
- if errors or not user_input:
- return self.async_show_form(
- step_id="user", data_schema=GET_EMAIL_SCHEMA, errors=errors
- )
- return await self.async_step_region()
-
- async def async_step_region(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Invoke when a user initiates a flow via the user interface."""
- errors: dict[str, str] = {}
- if not user_input:
- # fetch the list of regions names and urls from the api
- # for the user to select from.
- try:
- self.region_names = await self.p_api.request_region_names()
- _LOGGER.debug(
- "region names %s",
- ",".join(list(self.region_names.keys())),
- )
- except MyPermobilAPIException:
- _LOGGER.exception("Error requesting regions")
- errors["base"] = "region_fetch_error"
-
- else:
- region_url = self.region_names[user_input[CONF_REGION]]
-
- self.data[CONF_REGION] = region_url
- self.p_api.set_region(region_url)
- _LOGGER.debug("region %s", self.p_api.region)
- try:
- # tell backend to send code to the users email
- await self.p_api.request_application_code()
- except MyPermobilAPIException:
- _LOGGER.exception("Error requesting code")
- errors["base"] = "code_request_error"
-
- if errors or not user_input:
- # the error could either be that the fetch region did not pass
- # or that the request application code failed
- schema = vol.Schema(
- {
- vol.Required(CONF_REGION): selector.SelectSelector(
- selector.SelectSelectorConfig(
- options=list(self.region_names.keys()),
- mode=selector.SelectSelectorMode.DROPDOWN,
- )
- ),
- }
- )
- return self.async_show_form(
- step_id="region", data_schema=schema, errors=errors
- )
-
- return await self.async_step_email_code()
-
- async def async_step_email_code(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Second step in config flow to enter the email code."""
- errors: dict[str, str] = {}
-
- if user_input:
- try:
- self.p_api.set_code(user_input[CONF_CODE])
- self.data.update(user_input)
- token, ttl = await self.p_api.request_application_token()
- self.data[CONF_TOKEN] = token
- self.data[CONF_TTL] = ttl
- except MyPermobilAPIException, MyPermobilClientException:
- # the code did not pass validation by the api client
- # or the backend returned an error when trying to validate the code
- _LOGGER.exception("Error verifying code")
- errors["base"] = "invalid_code"
- except MyPermobilEulaException:
- # The user has not accepted the EULA
- errors["base"] = "unsigned_eula"
-
- if errors or not user_input:
- return self.async_show_form(
- step_id="email_code",
- data_schema=GET_TOKEN_SCHEMA,
- errors=errors,
- description_placeholders={"app_name": "MyPermobil"},
- )
-
- if self.source == SOURCE_REAUTH:
- return self.async_update_reload_and_abort(
- self._get_reauth_entry(), title=self.data[CONF_EMAIL], data=self.data
- )
-
- return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data)
-
- async def async_step_reauth(
- self, entry_data: Mapping[str, Any]
- ) -> ConfigFlowResult:
- """Perform reauth upon an API authentication error."""
- try:
- email: str = entry_data[CONF_EMAIL]
- region: str = entry_data[CONF_REGION]
- self.p_api.set_email(email)
- self.p_api.set_region(region)
- self.data = {
- CONF_EMAIL: email,
- CONF_REGION: region,
- }
- await self.p_api.request_application_code()
- except MyPermobilAPIException:
- _LOGGER.exception("Error requesting code for reauth")
- return self.async_abort(reason="unknown")
-
- return await self.async_step_email_code()
diff --git a/homeassistant/components/permobil/const.py b/homeassistant/components/permobil/const.py
deleted file mode 100644
index fd5fe673f2a7..000000000000
--- a/homeassistant/components/permobil/const.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Constants for the MyPermobil integration."""
-
-DOMAIN = "permobil"
-
-APPLICATION = "Home Assistant"
-
-
-BATTERY_ASSUMED_VOLTAGE = 25.0 # This is the average voltage over all states of charge
-REGIONS = "regions"
-KM = "kilometers"
-MILES = "miles"
diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py
deleted file mode 100644
index 13273949e964..000000000000
--- a/homeassistant/components/permobil/coordinator.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""DataUpdateCoordinator for permobil integration."""
-
-import asyncio
-from dataclasses import dataclass
-from datetime import timedelta
-import logging
-from typing import override
-
-from mypermobil import MyPermobil, MyPermobilAPIException
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-
-_LOGGER = logging.getLogger(__name__)
-
-type PermobilConfigEntry = ConfigEntry[MyPermobilCoordinator]
-
-
-@dataclass
-class MyPermobilData:
- """MyPermobil data stored in the DataUpdateCoordinator."""
-
- battery: dict[str, str | float | int | bool | list | dict]
- daily_usage: dict[str, str | float | int | list | dict]
- records: dict[str, str | float | int | list | dict]
-
-
-class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]):
- """MyPermobil coordinator."""
-
- config_entry: PermobilConfigEntry
-
- def __init__(
- self, hass: HomeAssistant, config_entry: PermobilConfigEntry, p_api: MyPermobil
- ) -> None:
- """Initialize my coordinator."""
- super().__init__(
- hass,
- _LOGGER,
- config_entry=config_entry,
- name="permobil",
- update_interval=timedelta(minutes=5),
- )
- self.p_api = p_api
-
- @override
- async def _async_update_data(self) -> MyPermobilData:
- """Fetch data from the 3 API endpoints."""
- try:
- async with asyncio.timeout(10):
- battery = await self.p_api.get_battery_info()
- daily_usage = await self.p_api.get_daily_usage()
- records = await self.p_api.get_usage_records()
- return MyPermobilData(
- battery=battery,
- daily_usage=daily_usage,
- records=records,
- )
-
- except MyPermobilAPIException as err:
- _LOGGER.exception(
- "Error fetching data from MyPermobil API for account %s",
- self.p_api.email,
- )
- raise UpdateFailed from err
diff --git a/homeassistant/components/permobil/entity.py b/homeassistant/components/permobil/entity.py
deleted file mode 100644
index 702781aa361e..000000000000
--- a/homeassistant/components/permobil/entity.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""PermobilEntity class."""
-
-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 MyPermobilCoordinator
-
-
-class PermobilEntity(CoordinatorEntity[MyPermobilCoordinator]):
- """Representation of a permobil Entity."""
-
- _attr_has_entity_name = True
-
- def __init__(
- self,
- coordinator: MyPermobilCoordinator,
- description: EntityDescription,
- ) -> None:
- """Initialize the entity."""
- super().__init__(coordinator)
- self.entity_description = description
- self._attr_unique_id = f"{coordinator.p_api.email}_{description.key}"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, coordinator.p_api.email)},
- manufacturer="Permobil",
- name="Permobil Wheelchair",
- )
diff --git a/homeassistant/components/permobil/icons.json b/homeassistant/components/permobil/icons.json
deleted file mode 100644
index 53bddcc00a97..000000000000
--- a/homeassistant/components/permobil/icons.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "entity": {
- "sensor": {
- "charge_time_left": {
- "default": "mdi:battery-clock"
- },
- "distance_left": {
- "default": "mdi:map-marker-distance"
- },
- "max_distance_left": {
- "default": "mdi:map-marker-distance"
- },
- "max_watt_hours": {
- "default": "mdi:lightning-bolt"
- },
- "record_adjustments": {
- "default": "mdi:seat-recline-extra"
- },
- "record_distance": {
- "default": "mdi:map-marker-distance"
- },
- "state_of_health": {
- "default": "mdi:battery-heart-variant"
- },
- "usage_adjustments": {
- "default": "mdi:seat-recline-extra"
- },
- "usage_distance": {
- "default": "mdi:map-marker-distance"
- },
- "watt_hours_left": {
- "default": "mdi:lightning-bolt"
- }
- }
- }
-}
diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json
index 7bba8182c04a..ab37dd5d0ed6 100644
--- a/homeassistant/components/permobil/manifest.json
+++ b/homeassistant/components/permobil/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "permobil",
"name": "MyPermobil",
- "codeowners": ["@IsakNyberg"],
- "config_flow": true,
+ "codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/permobil",
"integration_type": "device",
"iot_class": "cloud_polling",
- "requirements": ["mypermobil==0.1.8"]
+ "quality_scale": "legacy",
+ "requirements": []
}
diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py
deleted file mode 100644
index a1c35e034023..000000000000
--- a/homeassistant/components/permobil/sensor.py
+++ /dev/null
@@ -1,222 +0,0 @@
-"""Platform for sensor integration."""
-
-from collections.abc import Callable
-from dataclasses import dataclass
-import logging
-from typing import Any, override
-
-from mypermobil import (
- BATTERY_AMPERE_HOURS_LEFT,
- BATTERY_CHARGE_TIME_LEFT,
- BATTERY_DISTANCE_LEFT,
- BATTERY_INDOOR_DRIVE_TIME,
- BATTERY_MAX_AMPERE_HOURS,
- BATTERY_MAX_DISTANCE_LEFT,
- BATTERY_STATE_OF_CHARGE,
- BATTERY_STATE_OF_HEALTH,
- RECORDS_DISTANCE,
- RECORDS_DISTANCE_UNIT,
- RECORDS_SEATING,
- USAGE_ADJUSTMENTS,
- USAGE_DISTANCE,
-)
-
-from homeassistant.components.sensor import (
- SensorDeviceClass,
- SensorEntity,
- SensorEntityDescription,
- SensorStateClass,
-)
-from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-
-from .const import BATTERY_ASSUMED_VOLTAGE, KM, MILES
-from .coordinator import PermobilConfigEntry
-from .entity import PermobilEntity
-
-_LOGGER = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, kw_only=True)
-class PermobilSensorEntityDescription(SensorEntityDescription):
- """Describes Permobil sensor entity."""
-
- value_fn: Callable[[Any], float | int]
- available_fn: Callable[[Any], bool]
-
-
-SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = (
- PermobilSensorEntityDescription(
- # Current battery as a percentage
- value_fn=lambda data: data.battery[BATTERY_STATE_OF_CHARGE[0]],
- available_fn=lambda data: BATTERY_STATE_OF_CHARGE[0] in data.battery,
- key="state_of_charge",
- translation_key="state_of_charge",
- native_unit_of_measurement=PERCENTAGE,
- device_class=SensorDeviceClass.BATTERY,
- state_class=SensorStateClass.MEASUREMENT,
- ),
- PermobilSensorEntityDescription(
- # Current battery health as a percentage of original capacity
- value_fn=lambda data: data.battery[BATTERY_STATE_OF_HEALTH[0]],
- available_fn=lambda data: BATTERY_STATE_OF_HEALTH[0] in data.battery,
- key="state_of_health",
- translation_key="state_of_health",
- native_unit_of_measurement=PERCENTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- ),
- PermobilSensorEntityDescription(
- # Time until fully charged (displays 0 if not charging)
- value_fn=lambda data: data.battery[BATTERY_CHARGE_TIME_LEFT[0]],
- available_fn=lambda data: BATTERY_CHARGE_TIME_LEFT[0] in data.battery,
- key="charge_time_left",
- translation_key="charge_time_left",
- native_unit_of_measurement=UnitOfTime.HOURS,
- device_class=SensorDeviceClass.DURATION,
- ),
- PermobilSensorEntityDescription(
- # Distance possible on current change (km)
- value_fn=lambda data: data.battery[BATTERY_DISTANCE_LEFT[0]],
- available_fn=lambda data: BATTERY_DISTANCE_LEFT[0] in data.battery,
- key="distance_left",
- translation_key="distance_left",
- native_unit_of_measurement=UnitOfLength.KILOMETERS,
- device_class=SensorDeviceClass.DISTANCE,
- ),
- PermobilSensorEntityDescription(
- # Drive time possible on current charge
- value_fn=lambda data: data.battery[BATTERY_INDOOR_DRIVE_TIME[0]],
- available_fn=lambda data: BATTERY_INDOOR_DRIVE_TIME[0] in data.battery,
- key="indoor_drive_time",
- translation_key="indoor_drive_time",
- native_unit_of_measurement=UnitOfTime.HOURS,
- device_class=SensorDeviceClass.DURATION,
- ),
- PermobilSensorEntityDescription(
- # Watt hours the battery can store given battery health
- value_fn=lambda data: (
- data.battery[BATTERY_MAX_AMPERE_HOURS[0]] * BATTERY_ASSUMED_VOLTAGE
- ),
- available_fn=lambda data: BATTERY_MAX_AMPERE_HOURS[0] in data.battery,
- key="max_watt_hours",
- translation_key="max_watt_hours",
- native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
- device_class=SensorDeviceClass.ENERGY_STORAGE,
- state_class=SensorStateClass.MEASUREMENT,
- ),
- PermobilSensorEntityDescription(
- # Current amount of watt hours in battery
- value_fn=lambda data: (
- data.battery[BATTERY_AMPERE_HOURS_LEFT[0]] * BATTERY_ASSUMED_VOLTAGE
- ),
- available_fn=lambda data: BATTERY_AMPERE_HOURS_LEFT[0] in data.battery,
- key="watt_hours_left",
- translation_key="watt_hours_left",
- native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
- device_class=SensorDeviceClass.ENERGY_STORAGE,
- state_class=SensorStateClass.MEASUREMENT,
- ),
- PermobilSensorEntityDescription(
- # Distance that can be traveled with full charge given battery health (km)
- value_fn=lambda data: data.battery[BATTERY_MAX_DISTANCE_LEFT[0]],
- available_fn=lambda data: BATTERY_MAX_DISTANCE_LEFT[0] in data.battery,
- key="max_distance_left",
- translation_key="max_distance_left",
- native_unit_of_measurement=UnitOfLength.KILOMETERS,
- device_class=SensorDeviceClass.DISTANCE,
- ),
- PermobilSensorEntityDescription(
- # Distance traveled today monotonically increasing, resets every 24h (km)
- value_fn=lambda data: data.daily_usage[USAGE_DISTANCE[0]],
- available_fn=lambda data: USAGE_DISTANCE[0] in data.daily_usage,
- key="usage_distance",
- translation_key="usage_distance",
- native_unit_of_measurement=UnitOfLength.KILOMETERS,
- device_class=SensorDeviceClass.DISTANCE,
- state_class=SensorStateClass.TOTAL_INCREASING,
- ),
- PermobilSensorEntityDescription(
- # Number of adjustments monotonically increasing, resets every 24h
- value_fn=lambda data: data.daily_usage[USAGE_ADJUSTMENTS[0]],
- available_fn=lambda data: USAGE_ADJUSTMENTS[0] in data.daily_usage,
- key="usage_adjustments",
- translation_key="usage_adjustments",
- native_unit_of_measurement="adjustments",
- state_class=SensorStateClass.TOTAL_INCREASING,
- ),
- PermobilSensorEntityDescription(
- # Largest number of adjustments in a single 24h period,
- # monotonically increasing, never resets
- value_fn=lambda data: data.records[RECORDS_SEATING[0]],
- available_fn=lambda data: RECORDS_SEATING[0] in data.records,
- key="record_adjustments",
- translation_key="record_adjustments",
- native_unit_of_measurement="adjustments",
- state_class=SensorStateClass.TOTAL_INCREASING,
- ),
- PermobilSensorEntityDescription(
- # Record of largest distance travelled in a day,
- # monotonically increasing, never resets
- value_fn=lambda data: data.records[RECORDS_DISTANCE[0]],
- available_fn=lambda data: RECORDS_DISTANCE[0] in data.records,
- key="record_distance",
- translation_key="record_distance",
- device_class=SensorDeviceClass.DISTANCE,
- state_class=SensorStateClass.TOTAL_INCREASING,
- ),
-)
-
-DISTANCE_UNITS: dict[Any, UnitOfLength] = {
- KM: UnitOfLength.KILOMETERS,
- MILES: UnitOfLength.MILES,
-}
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: PermobilConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Create sensors from a config entry created in the integrations UI."""
-
- coordinator = config_entry.runtime_data
-
- async_add_entities(
- PermobilSensor(coordinator=coordinator, description=description)
- for description in SENSOR_DESCRIPTIONS
- )
-
-
-class PermobilSensor(PermobilEntity, SensorEntity):
- """Representation of a Sensor.
-
- This implements the common functions of all sensors.
- """
-
- _attr_suggested_display_precision = 0
- entity_description: PermobilSensorEntityDescription
-
- @property
- @override
- def native_unit_of_measurement(self) -> str | None:
- """Return the unit of measurement of the sensor."""
- if self.entity_description.key == "record_distance":
- return DISTANCE_UNITS.get(
- self.coordinator.data.records[RECORDS_DISTANCE_UNIT[0]]
- )
- return self.entity_description.native_unit_of_measurement
-
- @property
- @override
- def available(self) -> bool:
- """Return True if the sensor has value."""
- return super().available and self.entity_description.available_fn(
- self.coordinator.data
- )
-
- @property
- @override
- def native_value(self) -> float | int:
- """Return the value of the sensor."""
- return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json
index 12adf6dff5a7..5bbfe044bf5e 100644
--- a/homeassistant/components/permobil/strings.json
+++ b/homeassistant/components/permobil/strings.json
@@ -1,81 +1,8 @@
{
- "config": {
- "abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "unknown": "[%key:common::config_flow::error::unknown%]"
- },
- "error": {
- "code_request_error": "Error requesting application code",
- "invalid_code": "The code you gave is incorrect",
- "invalid_email": "Invalid email",
- "region_fetch_error": "Error fetching regions",
- "unknown": "Unexpected error, more information in the logs",
- "unsigned_eula": "Please sign the EULA in the {app_name} app"
- },
- "step": {
- "email_code": {
- "data": {
- "code": "Email code"
- },
- "description": "Enter the code that was sent to your email."
- },
- "region": {
- "data": {
- "region": "Region"
- },
- "description": "Select the region of your account."
- },
- "user": {
- "data": {
- "email": "Enter your permobil email"
- }
- }
- }
- },
- "entity": {
- "binary_sensor": {
- "is_charging": {
- "name": "Is charging"
- }
- },
- "sensor": {
- "charge_time_left": {
- "name": "Charge time left"
- },
- "distance_left": {
- "name": "Distance left"
- },
- "indoor_drive_time": {
- "name": "Indoor drive time"
- },
- "max_distance_left": {
- "name": "Full charge distance"
- },
- "max_watt_hours": {
- "name": "Battery max watt hours"
- },
- "record_adjustments": {
- "name": "Record number of adjustments"
- },
- "record_distance": {
- "name": "Record distance"
- },
- "state_of_charge": {
- "name": "Battery charge"
- },
- "state_of_health": {
- "name": "Battery health"
- },
- "usage_adjustments": {
- "name": "Number of adjustments"
- },
- "usage_distance": {
- "name": "Distance traveled"
- },
- "watt_hours_left": {
- "name": "Watt hours left"
- }
+ "issues": {
+ "integration_removed": {
+ "description": "The Permobil integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Permobil integration entries]({entries}).",
+ "title": "The Permobil integration has been removed"
}
}
}
diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py
index f2cae74394bf..1ab6c95f5607 100644
--- a/homeassistant/components/person/__init__.py
+++ b/homeassistant/components/person/__init__.py
@@ -9,15 +9,15 @@ import voluptuous as vol
from homeassistant.auth import EVENT_USER_REMOVED
from homeassistant.components import persistent_notification, websocket_api
from homeassistant.components.device_tracker import (
- ATTR_IN_ZONES,
- ATTR_SOURCE_TYPE,
- ATTR_TRACKING_TYPE,
DOMAIN as DEVICE_TRACKER_DOMAIN,
+ DeviceTrackerEntityCapabilityAttribute,
+ DeviceTrackerEntityStateAttribute,
SourceType,
+ TrackerEntityStateAttribute,
TrackingType,
)
from homeassistant.components.zone import ENTITY_ID_HOME
-from homeassistant.const import (
+from homeassistant.const import ( # noqa: F401
ATTR_EDITABLE,
ATTR_GPS_ACCURACY,
ATTR_ID,
@@ -31,6 +31,7 @@ from homeassistant.const import (
STATE_HOME,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import (
Event,
@@ -53,7 +54,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
-from .const import DOMAIN
+from .const import DOMAIN, PersonEntityStateAttribute
_LOGGER = logging.getLogger(__name__)
@@ -425,7 +426,9 @@ class Person(
):
"""Represent a tracked person."""
- _entity_component_unrecorded_attributes = frozenset({ATTR_DEVICE_TRACKERS})
+ _entity_component_unrecorded_attributes = frozenset(
+ {PersonEntityStateAttribute.DEVICE_TRACKERS}
+ )
_attr_should_poll = False
editable: bool
@@ -472,7 +475,15 @@ class Person(
"""Register device trackers."""
await super().async_added_to_hass()
if state := await self.async_get_last_state():
- self._parse_source_state(state)
+ self._parse_source_state(
+ state,
+ latitude=state.attributes.get(EntityStateAttribute.LATITUDE),
+ longitude=state.attributes.get(EntityStateAttribute.LONGITUDE),
+ gps_accuracy=state.attributes.get(
+ PersonEntityStateAttribute.GPS_ACCURACY
+ ),
+ in_zones=state.attributes.get(PersonEntityStateAttribute.IN_ZONES),
+ )
if self.hass.is_running:
# Update person now if hass is already running.
@@ -531,10 +542,15 @@ class Person(
continue
if state.attributes.get(
- ATTR_TRACKING_TYPE
- ) == TrackingType.CONNECTION and state.attributes.get(ATTR_IN_ZONES):
+ DeviceTrackerEntityCapabilityAttribute.TRACKING_TYPE
+ ) == TrackingType.CONNECTION and state.attributes.get(
+ DeviceTrackerEntityStateAttribute.IN_ZONES
+ ):
latest_connected = _get_latest(latest_connected, state)
- elif state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
+ elif (
+ state.attributes.get(DeviceTrackerEntityStateAttribute.SOURCE_TYPE)
+ == SourceType.GPS
+ ):
latest_gps = _get_latest(latest_gps, state)
elif state.state == STATE_HOME:
# Legacy scanner without tracking type
@@ -548,7 +564,17 @@ class Person(
latest = latest_connected or latest_legacy_home or latest_gps or latest_not_home
if latest:
- self._parse_source_state(latest)
+ self._parse_source_state(
+ latest,
+ latitude=latest.attributes.get(EntityStateAttribute.LATITUDE),
+ longitude=latest.attributes.get(EntityStateAttribute.LONGITUDE),
+ gps_accuracy=latest.attributes.get(
+ TrackerEntityStateAttribute.GPS_ACCURACY
+ ),
+ in_zones=latest.attributes.get(
+ DeviceTrackerEntityStateAttribute.IN_ZONES
+ ),
+ )
else:
self._attr_state = None
self._source = None
@@ -561,17 +587,28 @@ class Person(
self.async_write_ha_state()
@callback
- def _parse_source_state(self, state: State) -> None:
- """Parse source state and set person attributes.
+ def _parse_source_state(
+ self,
+ state: State,
+ *,
+ latitude: float | None,
+ longitude: float | None,
+ gps_accuracy: int | None,
+ in_zones: list[str] | None,
+ ) -> None:
+ """Set person attributes from a source state.
- This is a device tracker state or the restored person state.
+ The coordinates are read by the caller using the enum matching the
+ source, which is either a device tracker or the restored person state.
+ An absent ``in_zones`` (``None``) means the source does not report zone
+ membership.
"""
self._attr_state = state.state
self._source = state.entity_id
- self._latitude = state.attributes.get(ATTR_LATITUDE)
- self._longitude = state.attributes.get(ATTR_LONGITUDE)
- self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY)
- self._in_zones = state.attributes.get(ATTR_IN_ZONES, [])
+ self._latitude = latitude
+ self._longitude = longitude
+ self._gps_accuracy = gps_accuracy
+ self._in_zones = in_zones or []
# A legacy scanner (one that doesn't report in_zones) reports "home"
# without coordinates. Use the home zone's coordinates for backwards
@@ -579,35 +616,35 @@ class Person(
# trackers report in_zones and keep their own (possibly absent)
# coordinates.
if (
- ATTR_IN_ZONES not in state.attributes
+ in_zones is None
and state.state == STATE_HOME
and self._latitude is None
and self._longitude is None
and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) is not None
):
- self._latitude = home_zone.attributes.get(ATTR_LATITUDE)
- self._longitude = home_zone.attributes.get(ATTR_LONGITUDE)
+ self._latitude = home_zone.attributes.get(EntityStateAttribute.LATITUDE)
+ self._longitude = home_zone.attributes.get(EntityStateAttribute.LONGITUDE)
@callback
def _update_extra_state_attributes(self) -> None:
"""Update extra state attributes."""
data: dict[str, Any] = {
- ATTR_EDITABLE: self.editable,
- ATTR_ID: self.unique_id,
- ATTR_DEVICE_TRACKERS: self.device_trackers,
- ATTR_IN_ZONES: self._in_zones,
+ PersonEntityStateAttribute.EDITABLE: self.editable,
+ PersonEntityStateAttribute.ID: self.unique_id,
+ PersonEntityStateAttribute.DEVICE_TRACKERS: self.device_trackers,
+ PersonEntityStateAttribute.IN_ZONES: self._in_zones,
}
if self._latitude is not None:
- data[ATTR_LATITUDE] = self._latitude
+ data[EntityStateAttribute.LATITUDE] = self._latitude
if self._longitude is not None:
- data[ATTR_LONGITUDE] = self._longitude
+ data[EntityStateAttribute.LONGITUDE] = self._longitude
if self._gps_accuracy is not None:
- data[ATTR_GPS_ACCURACY] = self._gps_accuracy
+ data[PersonEntityStateAttribute.GPS_ACCURACY] = self._gps_accuracy
if self._source is not None:
- data[ATTR_SOURCE] = self._source
+ data[PersonEntityStateAttribute.SOURCE] = self._source
if (user_id := self._config.get(CONF_USER_ID)) is not None:
- data[ATTR_USER_ID] = user_id
+ data[PersonEntityStateAttribute.USER_ID] = user_id
self._attr_extra_state_attributes = data
diff --git a/homeassistant/components/person/const.py b/homeassistant/components/person/const.py
index dbd228b333ee..1945aafaeea6 100644
--- a/homeassistant/components/person/const.py
+++ b/homeassistant/components/person/const.py
@@ -1,3 +1,17 @@
"""Constants for the person entity platform."""
+from enum import StrEnum
+
DOMAIN = "person"
+
+
+class PersonEntityStateAttribute(StrEnum):
+ """State attributes for person entities."""
+
+ EDITABLE = "editable"
+ ID = "id"
+ DEVICE_TRACKERS = "device_trackers"
+ IN_ZONES = "in_zones"
+ GPS_ACCURACY = "gps_accuracy"
+ SOURCE = "source"
+ USER_ID = "user_id"
diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json
index e80a925094ce..0d5bfe995397 100644
--- a/homeassistant/components/philips_js/manifest.json
+++ b/homeassistant/components/philips_js/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["haphilipsjs"],
- "requirements": ["ha-philipsjs==3.2.4"],
+ "requirements": ["ha-philipsjs==3.2.5"],
"zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."]
}
diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py
index 0c7336263d34..98330e34a0b2 100644
--- a/homeassistant/components/picnic/const.py
+++ b/homeassistant/components/picnic/const.py
@@ -1,5 +1,7 @@
"""Constants for the Picnic integration."""
+from datetime import timedelta
+
DOMAIN = "picnic"
SERVICE_ADD_PRODUCT_TO_CART = "add_product"
@@ -18,6 +20,11 @@ SLOT_DATA = "slot_data"
NEXT_DELIVERY_DATA = "next_delivery_data"
LAST_ORDER_DATA = "last_order_data"
+DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30)
+DELIVERY_UPDATE_INTERVAL = timedelta(minutes=1)
+DELIVERY_WINDOW_LEAD_TIME = timedelta(minutes=30)
+DELIVERY_WINDOW_LAG_TIME = timedelta(hours=2)
+
SENSOR_CART_ITEMS_COUNT = "cart_items_count"
SENSOR_CART_TOTAL_PRICE = "cart_total_price"
SENSOR_SELECTED_SLOT_START = "selected_slot_start"
@@ -32,5 +39,6 @@ SENSOR_LAST_ORDER_DELIVERY_TIME = "last_order_delivery_time"
SENSOR_LAST_ORDER_TOTAL_PRICE = "last_order_total_price"
SENSOR_NEXT_DELIVERY_ETA_START = "next_delivery_eta_start"
SENSOR_NEXT_DELIVERY_ETA_END = "next_delivery_eta_end"
+SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL = "next_delivery_estimated_arrival"
SENSOR_NEXT_DELIVERY_SLOT_START = "next_delivery_slot_start"
SENSOR_NEXT_DELIVERY_SLOT_END = "next_delivery_slot_end"
diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py
index 0a731c51b3fd..8cc2b21a5be5 100644
--- a/homeassistant/components/picnic/coordinator.py
+++ b/homeassistant/components/picnic/coordinator.py
@@ -15,8 +15,19 @@ from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util import dt as dt_util
-from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT_DATA
+from .const import (
+ ADDRESS,
+ CART_DATA,
+ DEFAULT_UPDATE_INTERVAL,
+ DELIVERY_UPDATE_INTERVAL,
+ DELIVERY_WINDOW_LAG_TIME,
+ DELIVERY_WINDOW_LEAD_TIME,
+ LAST_ORDER_DATA,
+ NEXT_DELIVERY_DATA,
+ SLOT_DATA,
+)
type PicnicConfigEntry = ConfigEntry[PicnicUpdateCoordinator]
@@ -42,12 +53,18 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator):
logger,
config_entry=config_entry,
name="Picnic coordinator",
- update_interval=timedelta(minutes=30),
+ update_interval=DEFAULT_UPDATE_INTERVAL,
)
@override
async def _async_update_data(self) -> dict:
"""Fetch data from API endpoint."""
+ # Recompute up front so failed refreshes also relax the cadence
+ if self.data:
+ self.update_interval = self._get_update_interval(
+ self.data.get(NEXT_DELIVERY_DATA)
+ )
+
try:
async with asyncio.timeout(10):
data = await self.hass.async_add_executor_job(self.fetch_data)
@@ -63,9 +80,45 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator):
"Timeout while connecting to the Picnic API", retry_after=120
) from error
+ self.update_interval = self._get_update_interval(data.get(NEXT_DELIVERY_DATA))
+
# Return the fetched data
return data
+ @staticmethod
+ def _get_update_interval(next_delivery: dict | None) -> timedelta:
+ """Poll faster around the delivery so the live ETA is picked up in time."""
+ if not next_delivery:
+ return DEFAULT_UPDATE_INTERVAL
+
+ eta = next_delivery.get("eta")
+ slot = next_delivery.get("slot")
+
+ start = end = None
+ if eta:
+ start = dt_util.parse_datetime(str(eta.get("start")))
+ end = dt_util.parse_datetime(str(eta.get("end")))
+ if (start is None or end is None) and slot:
+ start = dt_util.parse_datetime(str(slot.get("window_start")))
+ end = dt_util.parse_datetime(str(slot.get("window_end")))
+
+ if start is None or end is None:
+ return DEFAULT_UPDATE_INTERVAL
+
+ now = dt_util.utcnow()
+ window_start = start - DELIVERY_WINDOW_LEAD_TIME
+
+ if window_start <= now <= end + DELIVERY_WINDOW_LAG_TIME:
+ return DELIVERY_UPDATE_INTERVAL
+
+ if now < window_start:
+ return max(
+ DELIVERY_UPDATE_INTERVAL,
+ min(DEFAULT_UPDATE_INTERVAL, window_start - now),
+ )
+
+ return DEFAULT_UPDATE_INTERVAL
+
def fetch_data(self):
"""Fetch data from the Picnic API.
@@ -153,6 +206,11 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator):
if "eta2" in next_delivery:
del next_delivery["eta2"]
+ # The position response's eta (unix timestamp in milliseconds) feeds
+ # the estimated arrival sensor; the API only serves it shortly before
+ # the delivery, so that sensor is unknown outside that window
+ next_delivery["estimated_arrival"] = delivery_position.get("eta")
+
# Determine the total price by adding up the total price of all sub-orders
total_price = 0
for order in last_order.get("orders", []):
diff --git a/homeassistant/components/picnic/icons.json b/homeassistant/components/picnic/icons.json
index 9a28ea8e55f9..86b612ff97cf 100644
--- a/homeassistant/components/picnic/icons.json
+++ b/homeassistant/components/picnic/icons.json
@@ -25,6 +25,9 @@
"last_order_total_price": {
"default": "mdi:cash-marker"
},
+ "next_delivery_estimated_arrival": {
+ "default": "mdi:clock-fast"
+ },
"next_delivery_eta_end": {
"default": "mdi:clock-end"
},
diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py
index b9a2c152cab7..0deb382a31cc 100644
--- a/homeassistant/components/picnic/sensor.py
+++ b/homeassistant/components/picnic/sensor.py
@@ -29,6 +29,7 @@ from .const import (
SENSOR_LAST_ORDER_SLOT_START,
SENSOR_LAST_ORDER_STATUS,
SENSOR_LAST_ORDER_TOTAL_PRICE,
+ SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL,
SENSOR_NEXT_DELIVERY_ETA_END,
SENSOR_NEXT_DELIVERY_ETA_START,
SENSOR_NEXT_DELIVERY_SLOT_END,
@@ -166,6 +167,17 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = (
str(next_delivery.get("eta", {}).get("end"))
),
),
+ PicnicSensorEntityDescription(
+ key=SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL,
+ translation_key=SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ data_type="next_delivery_data",
+ value_fn=lambda next_delivery: (
+ dt_util.utc_from_timestamp(next_delivery["estimated_arrival"] / 1000)
+ if next_delivery.get("estimated_arrival")
+ else None
+ ),
+ ),
PicnicSensorEntityDescription(
key=SENSOR_NEXT_DELIVERY_SLOT_START,
translation_key=SENSOR_NEXT_DELIVERY_SLOT_START,
diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json
index e2cea9b4d4d2..8f597574362c 100644
--- a/homeassistant/components/picnic/strings.json
+++ b/homeassistant/components/picnic/strings.json
@@ -61,6 +61,9 @@
"last_order_total_price": {
"name": "Total price of last order"
},
+ "next_delivery_estimated_arrival": {
+ "name": "Estimated arrival of next delivery"
+ },
"next_delivery_eta_end": {
"name": "Expected end of next delivery"
},
diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py
index 5d4196537880..6b49adbfcf6b 100644
--- a/homeassistant/components/pilight/entity.py
+++ b/homeassistant/components/pilight/entity.py
@@ -4,6 +4,7 @@ from typing import Any, override
import voluptuous as vol
+from homeassistant.components.light import LightEntityStateAttribute
from homeassistant.const import (
CONF_ID,
CONF_NAME,
@@ -97,7 +98,9 @@ class PilightBaseDevice(RestoreEntity):
await super().async_added_to_hass()
if state := await self.async_get_last_state():
self._attr_is_on = state.state == STATE_ON
- self._brightness = state.attributes.get("brightness")
+ self._brightness = state.attributes.get(
+ LightEntityStateAttribute.BRIGHTNESS
+ )
def _handle_code(self, call):
"""Check if received code by the pilight-daemon.
diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py
index 0e4e772485a8..cb8e3e3e3c4d 100644
--- a/homeassistant/components/plex/__init__.py
+++ b/homeassistant/components/plex/__init__.py
@@ -1,4 +1,5 @@
"""Support to embed Plex."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from functools import partial
import logging
diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py
index c4d453c83483..79a52f858efd 100644
--- a/homeassistant/components/portainer/__init__.py
+++ b/homeassistant/components/portainer/__init__.py
@@ -1,8 +1,9 @@
"""The Portainer integration."""
+from datetime import timedelta
import logging
-from pyportainer import Portainer
+from pyportainer import Portainer, PortainerImageWatcher
from pyportainer.exceptions import PortainerError
from homeassistant.config_entries import ConfigEntry
@@ -12,9 +13,10 @@ from homeassistant.const import (
CONF_HOST,
CONF_URL,
CONF_VERIFY_SSL,
+ EVENT_HOMEASSISTANT_STOP,
Platform,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_create_clientsession
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr
@@ -32,6 +34,7 @@ _PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.SENSOR,
Platform.SWITCH,
+ Platform.UPDATE,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -55,8 +58,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
request_timeout=10,
max_retries=API_MAX_RETRIES,
)
+ watcher = PortainerImageWatcher(client, interval=timedelta(hours=24))
coordinator = PortainerCoordinator(hass, entry, client)
+ coordinator.watcher = watcher
await coordinator.async_config_entry_first_refresh()
docker_system_df_client = Portainer(
@@ -86,6 +91,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
+ @callback
+ def _start_watcher(_hass: HomeAssistant) -> None:
+ """Start the image watcher in the event loop."""
+ watcher.start()
+
+ @callback
+ def _stop_watcher(_event: Event) -> None:
+ """Stop the image watcher in the event loop."""
+ watcher.stop()
+
+ entry.async_on_unload(async_at_started(hass, _start_watcher))
+ entry.async_on_unload(watcher.stop)
+ entry.async_on_unload(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_watcher)
+ )
+
return True
diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py
index 8d4a2fd3ba5f..b2a4dd8b7d73 100644
--- a/homeassistant/components/portainer/button.py
+++ b/homeassistant/components/portainer/button.py
@@ -37,8 +37,18 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
-class PortainerButtonDescription(ButtonEntityDescription):
- """Class to describe a Portainer button entity."""
+class PortainerEndpointButtonDescription(ButtonEntityDescription):
+ """Class to describe a Portainer endpoint button entity."""
+
+ press_action: Callable[
+ [Portainer, int],
+ Coroutine[Any, Any, None | DockerContainer],
+ ]
+
+
+@dataclass(frozen=True, kw_only=True)
+class PortainerContainerButtonDescription(ButtonEntityDescription):
+ """Class to describe a Portainer container button entity."""
press_action: Callable[
[Portainer, int, str],
@@ -46,30 +56,30 @@ class PortainerButtonDescription(ButtonEntityDescription):
]
-ENDPOINT_BUTTONS: tuple[PortainerButtonDescription, ...] = (
- PortainerButtonDescription(
+ENDPOINT_BUTTONS: tuple[PortainerEndpointButtonDescription, ...] = (
+ PortainerEndpointButtonDescription(
key="images_prune",
translation_key="images_prune",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action=(
- lambda portainer, endpoint_id, _: portainer.images_prune(
+ lambda portainer, endpoint_id: portainer.images_prune(
endpoint_id=endpoint_id, dangling=False, until=timedelta(days=0)
)
),
),
- PortainerButtonDescription(
+ PortainerEndpointButtonDescription(
key="volumes_prune",
translation_key="volumes_prune",
entity_category=EntityCategory.CONFIG,
press_action=(
- lambda portainer, endpoint_id, _: portainer.prune_volumes(endpoint_id)
+ lambda portainer, endpoint_id: portainer.prune_volumes(endpoint_id)
),
),
)
-CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = (
- PortainerButtonDescription(
+CONTAINER_BUTTONS: tuple[PortainerContainerButtonDescription, ...] = (
+ PortainerContainerButtonDescription(
key="restart",
translation_key="restart_container",
device_class=ButtonDeviceClass.RESTART,
@@ -80,7 +90,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = (
)
),
),
- PortainerButtonDescription(
+ PortainerContainerButtonDescription(
key="pause",
translation_key="pause_container",
entity_category=EntityCategory.CONFIG,
@@ -90,7 +100,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = (
)
),
),
- PortainerButtonDescription(
+ PortainerContainerButtonDescription(
key="resume",
translation_key="resume_container",
entity_category=EntityCategory.CONFIG,
@@ -100,7 +110,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = (
)
),
),
- PortainerButtonDescription(
+ PortainerContainerButtonDescription(
key="recreate",
translation_key="recreate_container",
entity_category=EntityCategory.CONFIG,
@@ -113,7 +123,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = (
)
),
),
- PortainerButtonDescription(
+ PortainerContainerButtonDescription(
key="kill",
translation_key="kill_container",
entity_category=EntityCategory.CONFIG,
@@ -186,7 +196,6 @@ class PortainerBaseButton(ButtonEntity):
Ensures the async_press logic isn't duplicated.
"""
- entity_description: PortainerButtonDescription
coordinator: PortainerCoordinator
@abstractmethod
@@ -201,17 +210,17 @@ class PortainerBaseButton(ButtonEntity):
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="cannot_connect_no_details",
+ translation_key="cannot_connect",
) from err
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="invalid_auth_no_details",
+ translation_key="invalid_auth",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="timeout_connect_no_details",
+ translation_key="timeout_connect",
) from err
await self.coordinator.async_request_refresh()
@@ -220,20 +229,20 @@ class PortainerBaseButton(ButtonEntity):
class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton):
"""Defines a Portainer endpoint button."""
- entity_description: PortainerButtonDescription
+ entity_description: PortainerEndpointButtonDescription
@override
async def _async_press_call(self) -> None:
"""Call the endpoint button press action."""
await self.entity_description.press_action(
- self.coordinator.portainer, self.device_id, ""
+ self.coordinator.portainer, self.device_id
)
class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton):
"""Defines a Portainer button."""
- entity_description: PortainerButtonDescription
+ entity_description: PortainerContainerButtonDescription
@override
async def _async_press_call(self) -> None:
diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py
index 3ec635cca45c..8aa037d7b386 100644
--- a/homeassistant/components/portainer/config_flow.py
+++ b/homeassistant/components/portainer/config_flow.py
@@ -16,17 +16,26 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+ BooleanSelector,
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
- vol.Required(CONF_URL): str,
- vol.Required(CONF_API_TOKEN): str,
- vol.Optional(CONF_VERIFY_SSL, default=True): bool,
+ vol.Required(CONF_URL): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.URL)
+ ),
+ vol.Required(CONF_API_TOKEN): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ vol.Optional(CONF_VERIFY_SSL, default=True): BooleanSelector(),
}
)
@@ -126,7 +135,13 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="reauth_confirm",
- data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_API_TOKEN): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ )
+ }
+ ),
errors=errors,
)
@@ -182,13 +197,13 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
)
-class CannotConnect(HomeAssistantError):
+class CannotConnect(Exception):
"""Error to indicate we cannot connect."""
-class InvalidAuth(HomeAssistantError):
+class InvalidAuth(Exception):
"""Error to indicate there is invalid auth."""
-class PortainerTimeout(HomeAssistantError):
+class PortainerTimeout(Exception):
"""Error to indicate a timeout occurred."""
diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py
index afa8af0a16b6..80d3d37e3273 100644
--- a/homeassistant/components/portainer/coordinator.py
+++ b/homeassistant/components/portainer/coordinator.py
@@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
+import time
from typing import override
from pyportainer import (
@@ -22,10 +23,13 @@ from pyportainer.models.docker import (
DockerSystemDF,
DockerVolume,
DockerVolumeUsageData,
+ LocalImageInformation,
+ PortainerImageUpdateStatus,
)
-from pyportainer.models.docker_inspect import DockerInfo, DockerVersion
+from pyportainer.models.docker_inspect import DockerInfo, DockerInspect, DockerVersion
from pyportainer.models.portainer import Endpoint
from pyportainer.models.stacks import Stack
+from pyportainer.watcher import PortainerImageWatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
@@ -63,9 +67,12 @@ class PortainerContainerData:
"""Container data held by the Portainer coordinator."""
container: DockerContainer
+ container_inspect: DockerInspect
+ local_image: LocalImageInformation
+ stack: Stack | None
stats: DockerContainerStats | None
stats_pre: DockerContainerStats | None
- stack: Stack | None
+ image_status: PortainerImageUpdateStatus | None = None
@dataclass(slots=True)
@@ -129,24 +136,21 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
async def _async_setup(self) -> None:
"""Set up the Portainer Data Update Coordinator."""
try:
- await self.portainer.get_endpoints()
+ await self.portainer.portainer_system_status()
except PortainerAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
- translation_placeholders={"error": repr(err)},
) from err
except PortainerConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
- translation_placeholders={"error": repr(err)},
) from err
except PortainerTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
- translation_placeholders={"error": repr(err)},
) from err
@abstractmethod
@@ -162,19 +166,16 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
- translation_placeholders={"error": repr(err)},
) from err
except PortainerConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
- translation_placeholders={"error": repr(err)},
) from err
except PortainerTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
- translation_placeholders={"error": repr(err)},
) from err
@@ -185,8 +186,21 @@ class PortainerCoordinator(
config_entry: PortainerConfigEntry
docker_disk_space: PortainerDockerDiskSpaceCoordinator | None = None
+ watcher: PortainerImageWatcher | None = None
_update_interval = DEFAULT_SCAN_INTERVAL
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: PortainerConfigEntry,
+ portainer: Portainer,
+ ) -> None:
+ """Initialize."""
+ super().__init__(hass, config_entry, portainer)
+ self._image_cache: dict[
+ tuple[int, str], tuple[float, DockerInspect, LocalImageInformation]
+ ] = {}
+
@override
async def update_data(self) -> dict[int, PortainerCoordinatorData]:
"""Fetch data from Portainer API."""
@@ -201,13 +215,11 @@ class PortainerCoordinator(
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
- translation_placeholders={"error": repr(err)},
) from err
except PortainerConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
- translation_placeholders={"error": repr(err)},
) from err
mapped_endpoints: dict[int, PortainerCoordinatorData] = {}
@@ -271,6 +283,25 @@ class PortainerCoordinator(
else None
)
+ (
+ container_inspect,
+ local_image,
+ ) = await self._get_inspect_local_image(endpoint.id, container.id)
+
+ image_status = (
+ (
+ result.status
+ if (
+ result := self.watcher.results.get(
+ (endpoint.id, container.id)
+ )
+ )
+ else None
+ )
+ if self.watcher
+ else None
+ )
+
# Check if container belongs to a stack via docker compose label
stack_name: str | None = (
container.labels.get("com.docker.compose.project")
@@ -283,8 +314,11 @@ class PortainerCoordinator(
container_map[container_name] = PortainerContainerData(
container=container,
+ container_inspect=container_inspect,
+ local_image=local_image,
stats=None,
stats_pre=prev_container.stats if prev_container else None,
+ image_status=image_status,
stack=stack_map[stack_name].stack
if stack_name and stack_name in stack_map
else None,
@@ -432,6 +466,37 @@ class PortainerCoordinator(
for stack_callback in self.new_stacks_callbacks:
stack_callback(new_stack_data)
+ async def _get_inspect_local_image(
+ self, endpoint_id: int, container_id: str
+ ) -> tuple[DockerInspect, LocalImageInformation]:
+ """Fetch or retrieve cached container inspect and local image data."""
+ if cached := self._image_cache.get((endpoint_id, container_id)):
+ cached_at, container_inspect, local_image = cached
+ if (
+ self.watcher is None
+ or self.watcher.last_check is None
+ or cached_at >= self.watcher.last_check
+ ):
+ _LOGGER.debug(
+ "Using cached inspect and local image for endpoint %d, container %s",
+ endpoint_id,
+ container_id,
+ )
+ return container_inspect, local_image
+
+ container_inspect = await self.portainer.inspect_container(
+ endpoint_id, container_id
+ )
+ local_image = await self.portainer.get_image(
+ endpoint_id, str(container_inspect.image)
+ )
+ self._image_cache[(endpoint_id, container_id)] = (
+ time.monotonic(),
+ container_inspect,
+ local_image,
+ )
+ return container_inspect, local_image
+
class PortainerDockerDiskSpaceCoordinator(
PortainerBaseCoordinator[dict[int, DockerSystemDF]]
diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json
index f60fe1e30700..395fe0b96413 100644
--- a/homeassistant/components/portainer/manifest.json
+++ b/homeassistant/components/portainer/manifest.json
@@ -6,6 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/portainer",
"integration_type": "service",
"iot_class": "local_polling",
+ "loggers": ["pyportainer"],
"quality_scale": "platinum",
- "requirements": ["pyportainer==1.0.38"]
+ "requirements": ["pyportainer==1.0.42"]
}
diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py
index b1dd01675c0c..bbd910bd8cd0 100644
--- a/homeassistant/components/portainer/services.py
+++ b/homeassistant/components/portainer/services.py
@@ -10,10 +10,13 @@ from pyportainer import (
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers import config_validation as cv, device_registry as dr
-from homeassistant.helpers.service import async_extract_config_entry_ids
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ service,
+)
from .const import DOMAIN
from .coordinator import PortainerConfigEntry
@@ -47,39 +50,39 @@ SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema(
)
-async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry:
- """Extract config entry from the service call."""
- target_entry_ids = await async_extract_config_entry_ids(service_call)
- target_entries: list[PortainerConfigEntry] = [
- loaded_entry
- for loaded_entry in service_call.hass.config_entries.async_loaded_entries(
- DOMAIN
- )
- if loaded_entry.entry_id in target_entry_ids
- ]
- if not target_entries:
+@callback
+def _async_get_device(call: ServiceCall, device_id: str) -> dr.DeviceEntry:
+ """Get a device entry from a device ID."""
+ device_reg = dr.async_get(call.hass)
+ if (device := device_reg.async_get(device_id)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
- return target_entries[0]
+ return device
-async def _get_endpoint_id(
- call: ServiceCall,
+@callback
+def _async_get_entry_from_device(
+ call: ServiceCall, device: dr.DeviceEntry
+) -> PortainerConfigEntry:
+ """Resolve and validate the Portainer config entry for a device."""
+ for entry in call.hass.config_entries.async_entries(DOMAIN):
+ if entry.entry_id in device.config_entries:
+ return service.async_get_config_entry(call.hass, DOMAIN, entry.entry_id)
+
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_target",
+ )
+
+
+@callback
+def _async_get_endpoint_id(
+ device: dr.DeviceEntry,
config_entry: PortainerConfigEntry,
) -> int:
- """Get endpoint data from device ID."""
- device_reg = dr.async_get(call.hass)
- device_id = call.data[ATTR_DEVICE_ID]
- device = device_reg.async_get(device_id)
-
- if device is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="invalid_target",
- )
-
+ """Get the endpoint ID from a device entry."""
coordinator = config_entry.runtime_data
for data in coordinator.data.values():
@@ -95,39 +98,21 @@ async def _get_endpoint_id(
)
-async def _get_container_and_endpoint_ids(
- call: ServiceCall,
-) -> tuple[PortainerConfigEntry, int, str]:
- """Get config entry, endpoint ID and container ID from the container device ID."""
- device_reg = dr.async_get(call.hass)
- device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
-
- if device is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="invalid_target",
- )
-
- config_entry: PortainerConfigEntry | None = None
- for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
- if loaded_entry.entry_id in device.config_entries:
- config_entry = loaded_entry
- break
-
- if config_entry is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="invalid_target",
- )
-
+@callback
+def _async_get_container_and_endpoint_ids(
+ device: dr.DeviceEntry,
+ config_entry: PortainerConfigEntry,
+) -> tuple[int, str]:
+ """Get the endpoint ID and container ID from a container device entry."""
coordinator = config_entry.runtime_data
+
for data in coordinator.data.values():
for container_name, container_data in data.containers.items():
if (
DOMAIN,
f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}",
) in device.identifiers:
- return config_entry, data.endpoint.id, container_data.container.id
+ return data.endpoint.id, container_data.container.id
raise ServiceValidationError(
translation_domain=DOMAIN,
@@ -137,9 +122,10 @@ async def _get_container_and_endpoint_ids(
async def prune_images(call: ServiceCall) -> None:
"""Prune unused images in Portainer, with more controls."""
- config_entry = await _extract_config_entry(call)
+ device = _async_get_device(call, call.data[ATTR_DEVICE_ID])
+ config_entry = _async_get_entry_from_device(call, device)
coordinator = config_entry.runtime_data
- endpoint_id = await _get_endpoint_id(call, config_entry)
+ endpoint_id = _async_get_endpoint_id(device, config_entry)
try:
await coordinator.portainer.images_prune(
@@ -150,26 +136,28 @@ async def prune_images(call: ServiceCall) -> None:
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="invalid_auth_no_details",
+ translation_key="invalid_auth",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="cannot_connect_no_details",
+ translation_key="cannot_connect",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="timeout_connect_no_details",
+ translation_key="timeout_connect",
) from err
async def recreate_container(call: ServiceCall) -> None:
"""Recreate a container in Portainer, with more controls."""
- config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids(
- call
- )
+ device = _async_get_device(call, call.data[ATTR_CONTAINER_DEVICE_ID])
+ config_entry = _async_get_entry_from_device(call, device)
coordinator = config_entry.runtime_data
+ endpoint_id, container_id = _async_get_container_and_endpoint_ids(
+ device, config_entry
+ )
timeout: timedelta | None = call.data.get(ATTR_TIMEOUT)
try:
@@ -182,17 +170,17 @@ async def recreate_container(call: ServiceCall) -> None:
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="invalid_auth_no_details",
+ translation_key="invalid_auth",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="cannot_connect_no_details",
+ translation_key="cannot_connect",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="timeout_connect_no_details",
+ translation_key="timeout_connect",
) from err
await coordinator.async_request_refresh()
diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json
index d39cddc8c6c4..d32ebe40ce42 100644
--- a/homeassistant/components/portainer/strings.json
+++ b/homeassistant/components/portainer/strings.json
@@ -192,28 +192,24 @@
"stack": {
"name": "Stack"
}
+ },
+ "update": {
+ "container_image_update": {
+ "name": "Image update available"
+ }
}
},
"exceptions": {
"cannot_connect": {
- "message": "An error occurred while trying to connect to the Portainer instance: {error}"
- },
- "cannot_connect_no_details": {
"message": "An error occurred while trying to connect to the Portainer instance."
},
"invalid_auth": {
- "message": "An error occurred while trying to authenticate: {error}"
- },
- "invalid_auth_no_details": {
"message": "An error occurred while trying to authenticate."
},
"invalid_target": {
"message": "Invalid device targeted."
},
"timeout_connect": {
- "message": "A timeout occurred while trying to connect to the Portainer instance: {error}"
- },
- "timeout_connect_no_details": {
"message": "A timeout occurred while trying to connect to the Portainer instance."
}
},
diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py
index e95274a0103c..7c87e056d01c 100644
--- a/homeassistant/components/portainer/switch.py
+++ b/homeassistant/components/portainer/switch.py
@@ -65,17 +65,17 @@ async def _perform_action(
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="invalid_auth_no_details",
+ translation_key="invalid_auth",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="cannot_connect_no_details",
+ translation_key="cannot_connect",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="timeout_connect_no_details",
+ translation_key="timeout_connect",
) from err
else:
await coordinator.async_request_refresh()
diff --git a/homeassistant/components/portainer/update.py b/homeassistant/components/portainer/update.py
new file mode 100644
index 000000000000..da3d3f529e86
--- /dev/null
+++ b/homeassistant/components/portainer/update.py
@@ -0,0 +1,187 @@
+"""Support for Portainer container updates."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any, override
+
+from pyportainer import Portainer
+from pyportainer.exceptions import (
+ PortainerAuthenticationError,
+ PortainerConnectionError,
+)
+from pyportainer.models.docker import (
+ DockerContainer,
+ LocalImageInformation,
+ PortainerImageUpdateStatus,
+)
+
+from homeassistant.components.update import (
+ UpdateEntity,
+ UpdateEntityDescription,
+ UpdateEntityFeature,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import (
+ PortainerConfigEntry,
+ PortainerContainerData,
+ PortainerCoordinator,
+ PortainerCoordinatorData,
+)
+from .entity import PortainerContainerEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class PortainerContainerUpdateEntityDescription(UpdateEntityDescription):
+ """Describes Portainer container update entity."""
+
+ installed_version: Callable[[LocalImageInformation], str | None]
+ latest_version: Callable[[PortainerImageUpdateStatus | None], str | None]
+ update_func: Callable[
+ [Portainer, int, str],
+ Awaitable[DockerContainer],
+ ]
+
+
+PARALLEL_UPDATES = 1
+DEFAULT_RECREATE_TIMEOUT = timedelta(minutes=10)
+
+
+CONTAINER_IMAGE: tuple[PortainerContainerUpdateEntityDescription] = (
+ PortainerContainerUpdateEntityDescription(
+ key="container_image_update",
+ translation_key="container_image_update",
+ entity_category=EntityCategory.CONFIG,
+ installed_version=lambda data: (
+ data.repo_digests[0].split("@")[1]
+ if data.repo_digests and isinstance(data.repo_digests[0], str)
+ else None
+ ),
+ latest_version=lambda data: data.registry_digest if data is not None else None,
+ update_func=(
+ lambda portainer, endpoint_id, container_id: portainer.container_recreate(
+ endpoint_id=endpoint_id,
+ container_id=container_id,
+ timeout=DEFAULT_RECREATE_TIMEOUT,
+ pull_image=True,
+ )
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PortainerConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Portainer update entities based on a config entry."""
+ coordinator = entry.runtime_data
+
+ def _async_add_new_containers(
+ containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
+ ) -> None:
+ """Add new container update entities."""
+
+ async_add_entities(
+ PortainerContainerImageUpdateEntity(
+ coordinator,
+ entity_description,
+ container,
+ endpoint,
+ )
+ for (endpoint, container) in containers
+ for entity_description in CONTAINER_IMAGE
+ )
+
+ coordinator.new_containers_callbacks.append(_async_add_new_containers)
+ _async_add_new_containers(
+ [
+ (endpoint, container)
+ for endpoint in coordinator.data.values()
+ for container in endpoint.containers.values()
+ ]
+ )
+
+
+class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity):
+ """Representation of a Portainer container update."""
+
+ _attr_supported_features = (
+ UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
+ )
+
+ entity_description: PortainerContainerUpdateEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PortainerCoordinator,
+ entity_description: PortainerContainerUpdateEntityDescription,
+ device_info: PortainerContainerData,
+ via_device: PortainerCoordinatorData,
+ ) -> None:
+ """Initialize the Portainer update entity."""
+ self.entity_description = entity_description
+ super().__init__(coordinator, entity_description, device_info, via_device)
+
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
+ self._in_progress_old_version: str | None = None
+
+ @override
+ @property
+ def title(self) -> str | None:
+ """Return title."""
+ return self.device_name
+
+ @override
+ @property
+ def installed_version(self) -> str | None:
+ """Return installed version."""
+ return self.entity_description.installed_version(
+ self.container_data.local_image
+ )
+
+ @override
+ @property
+ def latest_version(self) -> str | None:
+ """Return latest version."""
+ return self.entity_description.latest_version(self.container_data.image_status)
+
+ @override
+ @property
+ def in_progress(self) -> bool:
+ """Return if an update is in progress."""
+ return self._in_progress_old_version == self.installed_version
+
+ @override
+ async def async_install(
+ self, version: str | None, backup: bool, **kwargs: Any
+ ) -> None:
+ """Install update."""
+ self._in_progress_old_version = self.installed_version
+ try:
+ await self.entity_description.update_func(
+ self.coordinator.portainer,
+ self.endpoint_id,
+ self.container_data.container.id,
+ )
+ except PortainerAuthenticationError as ex:
+ self.coordinator.config_entry.async_start_reauth(self.hass)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ ) from ex
+ except PortainerConnectionError as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ ) from ex
+ else:
+ await self.coordinator.async_request_refresh()
+ finally:
+ self._in_progress_old_version = None
diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py
index 95d20d7ff2b6..6dd60337e26d 100644
--- a/homeassistant/components/private_ble_device/coordinator.py
+++ b/homeassistant/components/private_ble_device/coordinator.py
@@ -1,4 +1,5 @@
"""Central manager for tracking devices with random but resolvable MAC addresses."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from collections.abc import Callable
import logging
diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py
index 39463745a0f3..1f61fb0a95a5 100644
--- a/homeassistant/components/profiler/__init__.py
+++ b/homeassistant/components/profiler/__init__.py
@@ -1,631 +1,31 @@
"""The profiler integration."""
-import asyncio
-from collections.abc import Generator
-import contextlib
-from contextlib import suppress
-from datetime import timedelta
-from functools import _lru_cache_wrapper
-import logging
-import reprlib
-import sys
-import threading
-import time
-import traceback
-from typing import Any, cast
-
-from lru import LRU
-import voluptuous as vol
-
-from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE
-from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.event import async_track_time_interval
-from homeassistant.helpers.service import async_register_admin_service
+from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
+from .services import LOG_INTERVAL_SUB, async_setup_services
-SERVICE_START = "start"
-SERVICE_MEMORY = "memory"
-SERVICE_START_LOG_OBJECTS = "start_log_objects"
-SERVICE_STOP_LOG_OBJECTS = "stop_log_objects"
-SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources"
-SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources"
-SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects"
-SERVICE_DUMP_SOCKETS = "dump_sockets"
-SERVICE_LRU_STATS = "lru_stats"
-SERVICE_LOG_THREAD_FRAMES = "log_thread_frames"
-SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled"
-SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug"
-SERVICE_LOG_CURRENT_TASKS = "log_current_tasks"
-
-_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__
-_SQLALCHEMY_LRU_OBJECT = "LRUCache"
-
-_KNOWN_LRU_CLASSES = (
- "EventDataManager",
- "EventTypeManager",
- "StatesMetaManager",
- "StateAttributesManager",
- "StatisticsMetaManager",
-)
-
-SERVICES = (
- SERVICE_START,
- SERVICE_MEMORY,
- SERVICE_START_LOG_OBJECTS,
- SERVICE_STOP_LOG_OBJECTS,
- SERVICE_DUMP_LOG_OBJECTS,
- SERVICE_LRU_STATS,
- SERVICE_LOG_THREAD_FRAMES,
- SERVICE_LOG_EVENT_LOOP_SCHEDULED,
- SERVICE_SET_ASYNCIO_DEBUG,
- SERVICE_LOG_CURRENT_TASKS,
-)
-
-DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
-
-DEFAULT_MAX_OBJECTS = 5
-
-CONF_SECONDS = "seconds"
-CONF_MAX_OBJECTS = "max_objects"
-
-LOG_INTERVAL_SUB = "log_interval_subscription"
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-_LOGGER = logging.getLogger(__name__)
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Profiler."""
+ async_setup_services(hass)
+ return True
-async def async_setup_entry( # noqa: C901
- hass: HomeAssistant, entry: ConfigEntry
-) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Profiler from a config entry."""
- lock = asyncio.Lock()
- # Uses legacy hass.data[DOMAIN] pattern
- # pylint: disable-next=home-assistant-use-runtime-data
- domain_data = hass.data[DOMAIN] = {}
-
- async def _async_run_profile(call: ServiceCall) -> None:
- async with lock:
- await _async_generate_profile(hass, call)
-
- async def _async_run_memory_profile(call: ServiceCall) -> None:
- async with lock:
- await _async_generate_memory_profile(hass, call)
-
- async def _async_start_log_objects(call: ServiceCall) -> None:
- if LOG_INTERVAL_SUB in domain_data:
- raise HomeAssistantError("Object logging already started")
-
- persistent_notification.async_create(
- hass,
- (
- "Object growth logging has started. See [the logs](/config/logs) to"
- " track the growth of new objects."
- ),
- title="Object growth logging started",
- notification_id="profile_object_logging",
- )
- await hass.async_add_executor_job(_log_objects)
- domain_data[LOG_INTERVAL_SUB] = async_track_time_interval(
- hass, _log_objects, call.data[CONF_SCAN_INTERVAL]
- )
-
- async def _async_stop_log_objects(call: ServiceCall) -> None:
- if LOG_INTERVAL_SUB not in domain_data:
- raise HomeAssistantError("Object logging not running")
-
- persistent_notification.async_dismiss(hass, "profile_object_logging")
- domain_data.pop(LOG_INTERVAL_SUB)()
-
- async def _async_start_object_sources(call: ServiceCall) -> None:
- if LOG_INTERVAL_SUB in domain_data:
- raise HomeAssistantError("Object logging already started")
-
- persistent_notification.async_create(
- hass,
- (
- "Object source logging has started. See [the logs](/config/logs) to"
- " track the growth of new objects."
- ),
- title="Object source logging started",
- notification_id="profile_object_source_logging",
- )
-
- last_ids: set[int] = set()
- last_stats: dict[str, int] = {}
-
- async def _log_object_sources_with_max(*_: Any) -> None:
- await hass.async_add_executor_job(
- _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats
- )
-
- await _log_object_sources_with_max()
- cancel_track = async_track_time_interval(
- hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL]
- )
-
- @callback
- def _cancel():
- cancel_track()
- last_ids.clear()
- last_stats.clear()
-
- domain_data[LOG_INTERVAL_SUB] = _cancel
-
- @callback
- def _async_stop_object_sources(call: ServiceCall) -> None:
- if LOG_INTERVAL_SUB not in domain_data:
- raise HomeAssistantError("Object logging not running")
-
- persistent_notification.async_dismiss(hass, "profile_object_source_logging")
- domain_data.pop(LOG_INTERVAL_SUB)()
-
- def _dump_log_objects(call: ServiceCall) -> None:
- # Imports deferred to avoid loading modules
- # in memory since usually only one part of this
- # integration is used at a time
- import objgraph # noqa: PLC0415
-
- obj_type = call.data[CONF_TYPE]
-
- for obj in objgraph.by_type(obj_type):
- _LOGGER.critical(
- "%s object in memory: %s",
- obj_type,
- _safe_repr(obj),
- )
-
- persistent_notification.create(
- hass,
- (
- f"Objects with type {obj_type} have been dumped to the log. See [the"
- " logs](/config/logs) to review the repr of the objects."
- ),
- title="Object dump completed",
- notification_id="profile_object_dump",
- )
-
- def _lru_stats(call: ServiceCall) -> None:
- """Log the stats of all lru caches."""
- # Imports deferred to avoid loading modules
- # in memory since usually only one part of this
- # integration is used at a time
- import objgraph # noqa: PLC0415
-
- for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT):
- lru = cast(_lru_cache_wrapper, lru)
- _LOGGER.critical(
- "Cache stats for lru_cache %s at %s: %s",
- lru.__wrapped__,
- _get_function_absfile(lru.__wrapped__) or "unknown",
- lru.cache_info(),
- )
-
- for _class in _KNOWN_LRU_CLASSES:
- for class_with_lru_attr in objgraph.by_type(_class):
- for maybe_lru in class_with_lru_attr.__dict__.values():
- if isinstance(maybe_lru, LRU):
- _LOGGER.critical(
- "Cache stats for LRU %s at %s: %s",
- type(class_with_lru_attr),
- _get_function_absfile(class_with_lru_attr) or "unknown",
- maybe_lru.get_stats(),
- )
-
- for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT):
- if (data := getattr(lru, "_data", None)) and isinstance(data, dict):
- for key, value in dict(data).items():
- _LOGGER.critical(
- "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value
- )
-
- persistent_notification.create(
- hass,
- (
- "LRU cache states have been dumped to the log. See [the"
- " logs](/config/logs) to review the stats."
- ),
- title="LRU stats completed",
- notification_id="profile_lru_stats",
- )
-
- def _dump_sockets(call: ServiceCall) -> None:
- """Dump list of all currently existing sockets to the log."""
- import objgraph # noqa: PLC0415
-
- _LOGGER.critical(
- "Sockets used by Home Assistant:\n%s",
- "\n".join(repr(sock) for sock in objgraph.by_type("socket")),
- )
-
- async def _async_dump_thread_frames(call: ServiceCall) -> None:
- """Log all thread frames."""
- frames = sys._current_frames() # noqa: SLF001
- main_thread = threading.main_thread()
- for thread in threading.enumerate():
- if thread == main_thread:
- continue
- ident = cast(int, thread.ident)
- _LOGGER.critical(
- "Thread [%s]: %s",
- thread.name,
- "".join(traceback.format_stack(frames.get(ident))).strip(),
- )
-
- async def _async_dump_current_tasks(call: ServiceCall) -> None:
- """Log all current tasks in the event loop."""
- with _increase_repr_limit():
- for task in asyncio.all_tasks():
- if not task.cancelled():
- _LOGGER.critical("Task: %s", _safe_repr(task))
-
- async def _async_dump_scheduled(call: ServiceCall) -> None:
- """Log all scheduled in the event loop."""
- with _increase_repr_limit():
- handle: asyncio.Handle
- for handle in getattr(hass.loop, "_scheduled"): # noqa: B009
- if not handle.cancelled():
- _LOGGER.critical("Scheduled: %s", handle)
-
- async def _async_asyncio_debug(call: ServiceCall) -> None:
- """Enable or disable asyncio debug."""
- enabled = call.data[CONF_ENABLED]
- # Always log this at critical level so we know when
- # it's been changed when reviewing logs
- _LOGGER.critical("Setting asyncio debug to %s", enabled)
- # Make sure the logger is set to at least INFO or
- # we won't see the messages
- base_logger = logging.getLogger()
- if enabled and base_logger.getEffectiveLevel() > logging.INFO:
- base_logger.setLevel(logging.INFO)
- hass.loop.set_debug(enabled)
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_START,
- _async_run_profile,
- schema=vol.Schema(
- {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)}
- ),
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_MEMORY,
- _async_run_memory_profile,
- schema=vol.Schema(
- {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)}
- ),
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_START_LOG_OBJECTS,
- _async_start_log_objects,
- schema=vol.Schema(
- {
- vol.Optional(
- CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
- ): cv.time_period
- }
- ),
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_STOP_LOG_OBJECTS,
- _async_stop_log_objects,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_START_LOG_OBJECT_SOURCES,
- _async_start_object_sources,
- schema=vol.Schema(
- {
- vol.Optional(
- CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
- ): cv.time_period,
- vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range(
- min=1, max=1024
- ),
- }
- ),
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_STOP_LOG_OBJECT_SOURCES,
- _async_stop_object_sources,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_DUMP_LOG_OBJECTS,
- _dump_log_objects,
- schema=vol.Schema({vol.Required(CONF_TYPE): str}),
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_DUMP_SOCKETS,
- _dump_sockets,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_LRU_STATS,
- _lru_stats,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_LOG_THREAD_FRAMES,
- _async_dump_thread_frames,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_LOG_EVENT_LOOP_SCHEDULED,
- _async_dump_scheduled,
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_SET_ASYNCIO_DEBUG,
- _async_asyncio_debug,
- schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}),
- )
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(
- hass,
- DOMAIN,
- SERVICE_LOG_CURRENT_TASKS,
- _async_dump_current_tasks,
- )
-
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- for service in SERVICES:
- hass.services.async_remove(domain=DOMAIN, service=service)
+ # Uses legacy hass.data[DOMAIN] pattern
+ # pylint: disable-next=home-assistant-use-runtime-data
if LOG_INTERVAL_SUB in hass.data[DOMAIN]:
hass.data[DOMAIN][LOG_INTERVAL_SUB]()
- hass.data.pop(DOMAIN)
return True
-
-
-async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall):
- # Imports deferred to avoid loading modules
- # in memory since usually only one part of this
- # integration is used at a time
- import cProfile # noqa: PLC0415
-
- start_time = int(time.time() * 1000000)
- persistent_notification.async_create(
- hass,
- (
- "The profile has started. This notification will be updated when it is"
- " complete."
- ),
- title="Profile Started",
- notification_id=f"profiler_{start_time}",
- )
- profiler = cProfile.Profile()
- profiler.enable()
- await asyncio.sleep(float(call.data[CONF_SECONDS]))
- profiler.disable()
-
- cprofile_path = hass.config.path(f"profile.{start_time}.cprof")
- callgrind_path = hass.config.path(f"callgrind.out.{start_time}")
- await hass.async_add_executor_job(
- _write_profile, profiler, cprofile_path, callgrind_path
- )
- persistent_notification.async_create(
- hass,
- (
- f"Wrote cProfile data to {cprofile_path} and callgrind data to"
- f" {callgrind_path}"
- ),
- title="Profile Complete",
- notification_id=f"profiler_{start_time}",
- )
-
-
-async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall):
- # Imports deferred to avoid loading modules
- # in memory since usually only one part of this
- # integration is used at a time
- from guppy import hpy # noqa: PLC0415
-
- start_time = int(time.time() * 1000000)
- persistent_notification.async_create(
- hass,
- (
- "The memory profile has started. This notification will be updated when it"
- " is complete."
- ),
- title="Profile Started",
- notification_id=f"memory_profiler_{start_time}",
- )
- heap_profiler = hpy()
- heap_profiler.setref()
- await asyncio.sleep(float(call.data[CONF_SECONDS]))
- heap = heap_profiler.heap()
-
- heap_path = hass.config.path(f"heap_profile.{start_time}.hpy")
- await hass.async_add_executor_job(_write_memory_profile, heap, heap_path)
- persistent_notification.async_create(
- hass,
- f"Wrote heapy memory profile to {heap_path}",
- title="Profile Complete",
- notification_id=f"memory_profiler_{start_time}",
- )
-
-
-def _write_profile(profiler, cprofile_path, callgrind_path):
- # Imports deferred to avoid loading modules
- # in memory since usually only one part of this
- # integration is used at a time
- from pyprof2calltree import convert # noqa: PLC0415
-
- profiler.create_stats()
- profiler.dump_stats(cprofile_path)
- convert(profiler.getstats(), callgrind_path)
-
-
-def _write_memory_profile(heap, heap_path):
- heap.byrcs.dump(heap_path)
-
-
-def _log_objects(*_):
- # Imports deferred to avoid loading modules
- # in memory since usually only one part of this
- # integration is used at a time
- import objgraph # noqa: PLC0415
-
- _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000))
-
-
-def _get_function_absfile(func: Any) -> str | None:
- """Get the absolute file path of a function."""
- import inspect # noqa: PLC0415
-
- abs_file: str | None = None
- with suppress(Exception):
- abs_file = inspect.getabsfile(func)
- return abs_file
-
-
-def _safe_repr(obj: Any) -> str:
- """Get the repr of an object but keep going if there is an exception.
-
- We wrap repr to ensure if one object cannot be serialized, we can
- still get the rest.
- """
- try:
- return repr(obj)
- except Exception: # noqa: BLE001
- return f"Failed to serialize {type(obj)}"
-
-
-def _find_backrefs_not_to_self(_object: Any) -> list[str]:
- import objgraph # noqa: PLC0415
-
- return [
- _safe_repr(backref)
- for backref in objgraph.find_backref_chain(
- _object, lambda obj: obj is not _object
- )
- ]
-
-
-def _log_object_sources(
- max_objects: int, last_ids: set[int], last_stats: dict[str, int]
-) -> None:
- # Imports deferred to avoid loading modules
- # in memory since usually only one part of this
- # integration is used at a time
- import gc # noqa: PLC0415
-
- gc.collect()
-
- objects = gc.get_objects()
- new_objects: list[object] = []
- new_objects_overflow: dict[str, int] = {}
- current_ids = set()
- new_stats: dict[str, int] = {}
- had_new_object_growth = False
- try:
- for _object in objects:
- object_type = type(_object).__name__
- new_stats[object_type] = new_stats.get(object_type, 0) + 1
-
- for _object in objects:
- id_ = id(_object)
- current_ids.add(id_)
- if id_ in last_ids:
- continue
- object_type = type(_object).__name__
- if last_stats.get(object_type, 0) < new_stats[object_type]:
- if len(new_objects) < max_objects:
- new_objects.append(_object)
- else:
- new_objects_overflow.setdefault(object_type, 0)
- new_objects_overflow[object_type] += 1
-
- for _object in new_objects:
- had_new_object_growth = True
- object_type = type(_object).__name__
- _LOGGER.critical(
- "New object %s (%s/%s) at %s: %s",
- object_type,
- last_stats.get(object_type, 0),
- new_stats[object_type],
- _get_function_absfile(_object) or _find_backrefs_not_to_self(_object),
- _safe_repr(_object),
- )
-
- for object_type, count in last_stats.items():
- new_stats[object_type] = max(new_stats.get(object_type, 0), count)
- finally:
- # Break reference cycles
- del objects
- del new_objects
- last_ids.clear()
- last_ids.update(current_ids)
- last_stats.clear()
- last_stats.update(new_stats)
- del new_stats
- del current_ids
-
- if new_objects_overflow:
- _LOGGER.critical("New objects overflowed by %s", new_objects_overflow)
- elif not had_new_object_growth:
- _LOGGER.critical("No new object growth found")
-
-
-@contextlib.contextmanager
-def _increase_repr_limit() -> Generator[None]:
- """Increase the repr limit."""
- arepr = reprlib.aRepr
- original_maxstring = arepr.maxstring
- original_maxother = arepr.maxother
- arepr.maxstring = 300
- arepr.maxother = 300
- try:
- yield
- finally:
- arepr.maxstring = original_maxstring
- arepr.maxother = original_maxother
diff --git a/homeassistant/components/profiler/services.py b/homeassistant/components/profiler/services.py
new file mode 100644
index 000000000000..34c217d4f4fd
--- /dev/null
+++ b/homeassistant/components/profiler/services.py
@@ -0,0 +1,591 @@
+"""Support for the profiler services."""
+
+import asyncio
+from collections.abc import Generator
+import contextlib
+from contextlib import suppress
+from datetime import timedelta
+from functools import _lru_cache_wrapper
+import logging
+import reprlib
+import sys
+import threading
+import time
+import traceback
+from typing import Any, cast
+
+from lru import LRU
+import voluptuous as vol
+
+from homeassistant.components import persistent_notification
+from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.service import async_register_admin_service
+
+from .const import DOMAIN
+
+SERVICE_START = "start"
+SERVICE_MEMORY = "memory"
+SERVICE_START_LOG_OBJECTS = "start_log_objects"
+SERVICE_STOP_LOG_OBJECTS = "stop_log_objects"
+SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources"
+SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources"
+SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects"
+SERVICE_DUMP_SOCKETS = "dump_sockets"
+SERVICE_LRU_STATS = "lru_stats"
+SERVICE_LOG_THREAD_FRAMES = "log_thread_frames"
+SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled"
+SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug"
+SERVICE_LOG_CURRENT_TASKS = "log_current_tasks"
+
+_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__
+_SQLALCHEMY_LRU_OBJECT = "LRUCache"
+
+_KNOWN_LRU_CLASSES = (
+ "EventDataManager",
+ "EventTypeManager",
+ "StatesMetaManager",
+ "StateAttributesManager",
+ "StatisticsMetaManager",
+)
+
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
+
+DEFAULT_MAX_OBJECTS = 5
+
+CONF_SECONDS = "seconds"
+CONF_MAX_OBJECTS = "max_objects"
+
+LOG_INTERVAL_SUB = "log_interval_subscription"
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
+ """Register the profiler services."""
+ lock = asyncio.Lock()
+ # Uses legacy hass.data[DOMAIN] pattern
+ # pylint: disable-next=home-assistant-use-runtime-data
+ domain_data = hass.data[DOMAIN] = {}
+
+ async def _async_run_profile(call: ServiceCall) -> None:
+ async with lock:
+ await _async_generate_profile(hass, call)
+
+ async def _async_run_memory_profile(call: ServiceCall) -> None:
+ async with lock:
+ await _async_generate_memory_profile(hass, call)
+
+ async def _async_start_log_objects(call: ServiceCall) -> None:
+ if LOG_INTERVAL_SUB in domain_data:
+ raise HomeAssistantError("Object logging already started")
+
+ persistent_notification.async_create(
+ hass,
+ (
+ "Object growth logging has started. See [the logs](/config/logs) to"
+ " track the growth of new objects."
+ ),
+ title="Object growth logging started",
+ notification_id="profile_object_logging",
+ )
+ await hass.async_add_executor_job(_log_objects)
+ domain_data[LOG_INTERVAL_SUB] = async_track_time_interval(
+ hass, _log_objects, call.data[CONF_SCAN_INTERVAL]
+ )
+
+ async def _async_stop_log_objects(call: ServiceCall) -> None:
+ if LOG_INTERVAL_SUB not in domain_data:
+ raise HomeAssistantError("Object logging not running")
+
+ persistent_notification.async_dismiss(hass, "profile_object_logging")
+ domain_data.pop(LOG_INTERVAL_SUB)()
+
+ async def _async_start_object_sources(call: ServiceCall) -> None:
+ if LOG_INTERVAL_SUB in domain_data:
+ raise HomeAssistantError("Object logging already started")
+
+ persistent_notification.async_create(
+ hass,
+ (
+ "Object source logging has started. See [the logs](/config/logs) to"
+ " track the growth of new objects."
+ ),
+ title="Object source logging started",
+ notification_id="profile_object_source_logging",
+ )
+
+ last_ids: set[int] = set()
+ last_stats: dict[str, int] = {}
+
+ async def _log_object_sources_with_max(*_: Any) -> None:
+ await hass.async_add_executor_job(
+ _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats
+ )
+
+ await _log_object_sources_with_max()
+ cancel_track = async_track_time_interval(
+ hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL]
+ )
+
+ @callback
+ def _cancel():
+ cancel_track()
+ last_ids.clear()
+ last_stats.clear()
+
+ domain_data[LOG_INTERVAL_SUB] = _cancel
+
+ @callback
+ def _async_stop_object_sources(call: ServiceCall) -> None:
+ if LOG_INTERVAL_SUB not in domain_data:
+ raise HomeAssistantError("Object logging not running")
+
+ persistent_notification.async_dismiss(hass, "profile_object_source_logging")
+ domain_data.pop(LOG_INTERVAL_SUB)()
+
+ def _dump_log_objects(call: ServiceCall) -> None:
+ # Imports deferred to avoid loading modules
+ # in memory since usually only one part of this
+ # integration is used at a time
+ import objgraph # noqa: PLC0415
+
+ obj_type = call.data[CONF_TYPE]
+
+ for obj in objgraph.by_type(obj_type):
+ _LOGGER.critical(
+ "%s object in memory: %s",
+ obj_type,
+ _safe_repr(obj),
+ )
+
+ persistent_notification.create(
+ hass,
+ (
+ f"Objects with type {obj_type} have been dumped to the log. See [the"
+ " logs](/config/logs) to review the repr of the objects."
+ ),
+ title="Object dump completed",
+ notification_id="profile_object_dump",
+ )
+
+ def _lru_stats(call: ServiceCall) -> None:
+ """Log the stats of all lru caches."""
+ # Imports deferred to avoid loading modules
+ # in memory since usually only one part of this
+ # integration is used at a time
+ import objgraph # noqa: PLC0415
+
+ for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT):
+ lru = cast(_lru_cache_wrapper, lru)
+ _LOGGER.critical(
+ "Cache stats for lru_cache %s at %s: %s",
+ lru.__wrapped__,
+ _get_function_absfile(lru.__wrapped__) or "unknown",
+ lru.cache_info(),
+ )
+
+ for _class in _KNOWN_LRU_CLASSES:
+ for class_with_lru_attr in objgraph.by_type(_class):
+ for maybe_lru in class_with_lru_attr.__dict__.values():
+ if isinstance(maybe_lru, LRU):
+ _LOGGER.critical(
+ "Cache stats for LRU %s at %s: %s",
+ type(class_with_lru_attr),
+ _get_function_absfile(class_with_lru_attr) or "unknown",
+ maybe_lru.get_stats(),
+ )
+
+ for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT):
+ if (data := getattr(lru, "_data", None)) and isinstance(data, dict):
+ for key, value in dict(data).items():
+ _LOGGER.critical(
+ "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value
+ )
+
+ persistent_notification.create(
+ hass,
+ (
+ "LRU cache states have been dumped to the log. See [the"
+ " logs](/config/logs) to review the stats."
+ ),
+ title="LRU stats completed",
+ notification_id="profile_lru_stats",
+ )
+
+ def _dump_sockets(call: ServiceCall) -> None:
+ """Dump list of all currently existing sockets to the log."""
+ import objgraph # noqa: PLC0415
+
+ _LOGGER.critical(
+ "Sockets used by Home Assistant:\n%s",
+ "\n".join(repr(sock) for sock in objgraph.by_type("socket")),
+ )
+
+ async def _async_dump_thread_frames(call: ServiceCall) -> None:
+ """Log all thread frames."""
+ frames = sys._current_frames() # noqa: SLF001
+ main_thread = threading.main_thread()
+ for thread in threading.enumerate():
+ if thread == main_thread:
+ continue
+ ident = cast(int, thread.ident)
+ _LOGGER.critical(
+ "Thread [%s]: %s",
+ thread.name,
+ "".join(traceback.format_stack(frames.get(ident))).strip(),
+ )
+
+ async def _async_dump_current_tasks(call: ServiceCall) -> None:
+ """Log all current tasks in the event loop."""
+ with _increase_repr_limit():
+ for task in asyncio.all_tasks():
+ if not task.cancelled():
+ _LOGGER.critical("Task: %s", _safe_repr(task))
+
+ async def _async_dump_scheduled(call: ServiceCall) -> None:
+ """Log all scheduled in the event loop."""
+ with _increase_repr_limit():
+ handle: asyncio.Handle
+ for handle in getattr(hass.loop, "_scheduled"): # noqa: B009
+ if not handle.cancelled():
+ _LOGGER.critical("Scheduled: %s", handle)
+
+ async def _async_asyncio_debug(call: ServiceCall) -> None:
+ """Enable or disable asyncio debug."""
+ enabled = call.data[CONF_ENABLED]
+ # Always log this at critical level so we know when
+ # it's been changed when reviewing logs
+ _LOGGER.critical("Setting asyncio debug to %s", enabled)
+ # Make sure the logger is set to at least INFO or
+ # we won't see the messages
+ base_logger = logging.getLogger()
+ if enabled and base_logger.getEffectiveLevel() > logging.INFO:
+ base_logger.setLevel(logging.INFO)
+ hass.loop.set_debug(enabled)
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_START,
+ _async_run_profile,
+ schema=vol.Schema(
+ {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)}
+ ),
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_MEMORY,
+ _async_run_memory_profile,
+ schema=vol.Schema(
+ {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)}
+ ),
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_START_LOG_OBJECTS,
+ _async_start_log_objects,
+ schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
+ ): cv.time_period
+ }
+ ),
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_STOP_LOG_OBJECTS,
+ _async_stop_log_objects,
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_START_LOG_OBJECT_SOURCES,
+ _async_start_object_sources,
+ schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
+ ): cv.time_period,
+ vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range(
+ min=1, max=1024
+ ),
+ }
+ ),
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_STOP_LOG_OBJECT_SOURCES,
+ _async_stop_object_sources,
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_DUMP_LOG_OBJECTS,
+ _dump_log_objects,
+ schema=vol.Schema({vol.Required(CONF_TYPE): str}),
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_DUMP_SOCKETS,
+ _dump_sockets,
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_LRU_STATS,
+ _lru_stats,
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_LOG_THREAD_FRAMES,
+ _async_dump_thread_frames,
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_LOG_EVENT_LOOP_SCHEDULED,
+ _async_dump_scheduled,
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_SET_ASYNCIO_DEBUG,
+ _async_asyncio_debug,
+ schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}),
+ )
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_LOG_CURRENT_TASKS,
+ _async_dump_current_tasks,
+ )
+
+
+async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall):
+ # Imports deferred to avoid loading modules
+ # in memory since usually only one part of this
+ # integration is used at a time
+ import cProfile # noqa: PLC0415
+
+ start_time = int(time.time() * 1000000)
+ persistent_notification.async_create(
+ hass,
+ (
+ "The profile has started. This notification will be updated when it is"
+ " complete."
+ ),
+ title="Profile Started",
+ notification_id=f"profiler_{start_time}",
+ )
+ profiler = cProfile.Profile()
+ profiler.enable()
+ await asyncio.sleep(float(call.data[CONF_SECONDS]))
+ profiler.disable()
+
+ cprofile_path = hass.config.path(f"profile.{start_time}.cprof")
+ callgrind_path = hass.config.path(f"callgrind.out.{start_time}")
+ await hass.async_add_executor_job(
+ _write_profile, profiler, cprofile_path, callgrind_path
+ )
+ persistent_notification.async_create(
+ hass,
+ (
+ f"Wrote cProfile data to {cprofile_path} and callgrind data to"
+ f" {callgrind_path}"
+ ),
+ title="Profile Complete",
+ notification_id=f"profiler_{start_time}",
+ )
+
+
+async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall):
+ # Imports deferred to avoid loading modules
+ # in memory since usually only one part of this
+ # integration is used at a time
+ from guppy import hpy # noqa: PLC0415
+
+ start_time = int(time.time() * 1000000)
+ persistent_notification.async_create(
+ hass,
+ (
+ "The memory profile has started. This notification will be updated when it"
+ " is complete."
+ ),
+ title="Profile Started",
+ notification_id=f"memory_profiler_{start_time}",
+ )
+ heap_profiler = hpy()
+ heap_profiler.setref()
+ await asyncio.sleep(float(call.data[CONF_SECONDS]))
+ heap = heap_profiler.heap()
+
+ heap_path = hass.config.path(f"heap_profile.{start_time}.hpy")
+ await hass.async_add_executor_job(_write_memory_profile, heap, heap_path)
+ persistent_notification.async_create(
+ hass,
+ f"Wrote heapy memory profile to {heap_path}",
+ title="Profile Complete",
+ notification_id=f"memory_profiler_{start_time}",
+ )
+
+
+def _write_profile(profiler, cprofile_path, callgrind_path):
+ # Imports deferred to avoid loading modules
+ # in memory since usually only one part of this
+ # integration is used at a time
+ from pyprof2calltree import convert # noqa: PLC0415
+
+ profiler.create_stats()
+ profiler.dump_stats(cprofile_path)
+ convert(profiler.getstats(), callgrind_path)
+
+
+def _write_memory_profile(heap, heap_path):
+ heap.byrcs.dump(heap_path)
+
+
+def _log_objects(*_):
+ # Imports deferred to avoid loading modules
+ # in memory since usually only one part of this
+ # integration is used at a time
+ import objgraph # noqa: PLC0415
+
+ _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000))
+
+
+def _get_function_absfile(func: Any) -> str | None:
+ """Get the absolute file path of a function."""
+ import inspect # noqa: PLC0415
+
+ abs_file: str | None = None
+ with suppress(Exception):
+ abs_file = inspect.getabsfile(func)
+ return abs_file
+
+
+def _safe_repr(obj: Any) -> str:
+ """Get the repr of an object but keep going if there is an exception.
+
+ We wrap repr to ensure if one object cannot be serialized, we can
+ still get the rest.
+ """
+ try:
+ return repr(obj)
+ except Exception: # noqa: BLE001
+ return f"Failed to serialize {type(obj)}"
+
+
+def _find_backrefs_not_to_self(_object: Any) -> list[str]:
+ import objgraph # noqa: PLC0415
+
+ return [
+ _safe_repr(backref)
+ for backref in objgraph.find_backref_chain(
+ _object, lambda obj: obj is not _object
+ )
+ ]
+
+
+def _log_object_sources(
+ max_objects: int, last_ids: set[int], last_stats: dict[str, int]
+) -> None:
+ # Imports deferred to avoid loading modules
+ # in memory since usually only one part of this
+ # integration is used at a time
+ import gc # noqa: PLC0415
+
+ gc.collect()
+
+ objects = gc.get_objects()
+ new_objects: list[object] = []
+ new_objects_overflow: dict[str, int] = {}
+ current_ids = set()
+ new_stats: dict[str, int] = {}
+ had_new_object_growth = False
+ try:
+ for _object in objects:
+ object_type = type(_object).__name__
+ new_stats[object_type] = new_stats.get(object_type, 0) + 1
+
+ for _object in objects:
+ id_ = id(_object)
+ current_ids.add(id_)
+ if id_ in last_ids:
+ continue
+ object_type = type(_object).__name__
+ if last_stats.get(object_type, 0) < new_stats[object_type]:
+ if len(new_objects) < max_objects:
+ new_objects.append(_object)
+ else:
+ new_objects_overflow.setdefault(object_type, 0)
+ new_objects_overflow[object_type] += 1
+
+ for _object in new_objects:
+ had_new_object_growth = True
+ object_type = type(_object).__name__
+ _LOGGER.critical(
+ "New object %s (%s/%s) at %s: %s",
+ object_type,
+ last_stats.get(object_type, 0),
+ new_stats[object_type],
+ _get_function_absfile(_object) or _find_backrefs_not_to_self(_object),
+ _safe_repr(_object),
+ )
+
+ for object_type, count in last_stats.items():
+ new_stats[object_type] = max(new_stats.get(object_type, 0), count)
+ finally:
+ # Break reference cycles
+ del objects
+ del new_objects
+ last_ids.clear()
+ last_ids.update(current_ids)
+ last_stats.clear()
+ last_stats.update(new_stats)
+ del new_stats
+ del current_ids
+
+ if new_objects_overflow:
+ _LOGGER.critical("New objects overflowed by %s", new_objects_overflow)
+ elif not had_new_object_growth:
+ _LOGGER.critical("No new object growth found")
+
+
+@contextlib.contextmanager
+def _increase_repr_limit() -> Generator[None]:
+ """Increase the repr limit."""
+ arepr = reprlib.aRepr
+ original_maxstring = arepr.maxstring
+ original_maxother = arepr.maxother
+ arepr.maxstring = 300
+ arepr.maxother = 300
+ try:
+ yield
+ finally:
+ arepr.maxstring = original_maxstring
+ arepr.maxother = original_maxother
diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py
index 2904d9320f10..9ed5cfcea4de 100644
--- a/homeassistant/components/prometheus/__init__.py
+++ b/homeassistant/components/prometheus/__init__.py
@@ -15,54 +15,30 @@ import voluptuous as vol
from homeassistant import core as hacore
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
from homeassistant.components.climate import (
- ATTR_CURRENT_TEMPERATURE,
- ATTR_FAN_MODE,
- ATTR_FAN_MODES,
- ATTR_HVAC_ACTION,
- ATTR_HVAC_MODES,
- ATTR_TARGET_TEMP_HIGH,
- ATTR_TARGET_TEMP_LOW,
+ ClimateEntityCapabilityAttribute,
+ ClimateEntityStateAttribute,
HVACAction,
)
-from homeassistant.components.cover import (
- ATTR_CURRENT_POSITION,
- ATTR_CURRENT_TILT_POSITION,
-)
+from homeassistant.components.cover import CoverEntityStateAttribute
from homeassistant.components.fan import (
- ATTR_DIRECTION,
- ATTR_OSCILLATING,
- ATTR_PERCENTAGE,
- ATTR_PRESET_MODE,
- ATTR_PRESET_MODES,
DIRECTION_FORWARD,
DIRECTION_REVERSE,
+ FanEntityCapabilityAttribute,
+ FanEntityStateAttribute,
)
from homeassistant.components.http import KEY_HASS, HomeAssistantView
-from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY
-from homeassistant.components.light import ATTR_BRIGHTNESS
+from homeassistant.components.humidifier import (
+ HumidifierEntityCapabilityAttribute,
+ HumidifierEntityStateAttribute,
+)
+from homeassistant.components.light import LightEntityStateAttribute
from homeassistant.components.sensor import SensorDeviceClass
-
-# Alias water_heater constants to avoid name clashes with
-# similarly named climate constants
from homeassistant.components.water_heater import (
- ATTR_AWAY_MODE as WATER_HEATER_ATTR_AWAY_MODE,
- ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
- ATTR_MAX_TEMP as WATER_HEATER_ATTR_MAX_TEMP,
- ATTR_MIN_TEMP as WATER_HEATER_ATTR_MIN_TEMP,
- ATTR_OPERATION_LIST as WATER_HEATER_ATTR_OPERATION_LIST,
- ATTR_OPERATION_MODE as WATER_HEATER_ATTR_OPERATION_MODE,
- ATTR_TARGET_TEMP_HIGH as WATER_HEATER_ATTR_TARGET_TEMP_HIGH,
- ATTR_TARGET_TEMP_LOW as WATER_HEATER_ATTR_TARGET_TEMP_LOW,
+ WaterHeaterCapabilityAttribute,
+ WaterHeaterStateAttribute,
)
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
- ATTR_DEVICE_CLASS,
- ATTR_FRIENDLY_NAME,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
- ATTR_MODE,
- ATTR_TEMPERATURE,
- ATTR_UNIT_OF_MEASUREMENT,
CONTENT_TYPE_TEXT_PLAIN,
EVENT_STATE_CHANGED,
PERCENTAGE,
@@ -73,6 +49,7 @@ from homeassistant.const import (
STATE_OPENING,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
UnitOfLength,
UnitOfTemperature,
)
@@ -293,8 +270,8 @@ class PrometheusMetrics:
if (
old_state := event.data.get("old_state")
) is not None and old_state.attributes.get(
- ATTR_FRIENDLY_NAME
- ) != state.attributes.get(ATTR_FRIENDLY_NAME):
+ EntityStateAttribute.FRIENDLY_NAME
+ ) != state.attributes.get(EntityStateAttribute.FRIENDLY_NAME):
self._remove_labelsets(old_state.entity_id)
self.handle_state(state)
@@ -569,7 +546,10 @@ class PrometheusMetrics:
def state_as_number(state: State) -> float | None:
"""Return state as a float, or None if state cannot be converted."""
try:
- if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP:
+ if (
+ state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
+ == SensorDeviceClass.TIMESTAMP
+ ):
value = as_timestamp(state.state)
else:
value = state_helper.state_as_number(state)
@@ -588,7 +568,7 @@ class PrometheusMetrics:
labels = {
"entity": state.entity_id,
"domain": state.domain,
- "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME),
+ "friendly_name": state.attributes.get(EntityStateAttribute.FRIENDLY_NAME),
}
if not labels.keys().isdisjoint(extra_labels.keys()):
conflicting_keys = labels.keys() & extra_labels.keys()
@@ -731,7 +711,9 @@ class PrometheusMetrics:
if (value := self.state_as_number(state)) is None:
return
- if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
+ if unit := self._unit_string(
+ state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ ):
metric = self._metric(
f"{domain}_state_{unit}",
prometheus_client.Gauge,
@@ -747,7 +729,7 @@ class PrometheusMetrics:
)
if (
- state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
== UnitOfTemperature.FAHRENHEIT
):
value = TemperatureConverter.convert(
@@ -777,7 +759,7 @@ class PrometheusMetrics:
def _handle_geo_location(self, state: State) -> None:
labels = self._labels(state, {"source": state.attributes.get("source", "")})
if (value := self.state_as_number(state)) is not None:
- unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if unit is not None:
value = DistanceConverter.convert(value, unit, UnitOfLength.METERS)
self._metric(
@@ -786,14 +768,18 @@ class PrometheusMetrics:
"Distance of the geo location event from home in meters",
labels,
).set(value)
- if (latitude := state.attributes.get(ATTR_LATITUDE)) is not None:
+ if (
+ latitude := state.attributes.get(EntityStateAttribute.LATITUDE)
+ ) is not None:
self._metric(
"geo_location_latitude_degrees",
prometheus_client.Gauge,
"Latitude of the geo location event in degrees",
labels,
).set(latitude)
- if (longitude := state.attributes.get(ATTR_LONGITUDE)) is not None:
+ if (
+ longitude := state.attributes.get(EntityStateAttribute.LONGITUDE)
+ ) is not None:
self._metric(
"geo_location_longitude_degrees",
prometheus_client.Gauge,
@@ -815,13 +801,13 @@ class PrometheusMetrics:
)
self._float_metric(
state,
- ATTR_CURRENT_POSITION,
+ CoverEntityStateAttribute.CURRENT_POSITION,
"cover_position",
"Position of the cover (0-100)",
)
self._float_metric(
state,
- ATTR_CURRENT_TILT_POSITION,
+ CoverEntityStateAttribute.CURRENT_TILT_POSITION,
"cover_tilt_position",
"Tilt Position of the cover (0-100)",
)
@@ -830,7 +816,7 @@ class PrometheusMetrics:
if (value := self.state_as_number(state)) is None:
return
- brightness = state.attributes.get(ATTR_BRIGHTNESS)
+ brightness = state.attributes.get(LightEntityStateAttribute.BRIGHTNESS)
if state.state == STATE_ON and brightness is not None:
value = float(brightness) / 255.0
value = value * 100
@@ -845,25 +831,25 @@ class PrometheusMetrics:
def _handle_climate(self, state: State) -> None:
self._temperature_metric(
state,
- ATTR_TEMPERATURE,
+ ClimateEntityStateAttribute.TEMPERATURE,
"climate_target_temperature_celsius",
"Target temperature in degrees Celsius",
)
self._temperature_metric(
state,
- ATTR_TARGET_TEMP_HIGH,
+ ClimateEntityStateAttribute.TARGET_TEMP_HIGH,
"climate_target_temperature_high_celsius",
"Target high temperature in degrees Celsius",
)
self._temperature_metric(
state,
- ATTR_TARGET_TEMP_LOW,
+ ClimateEntityStateAttribute.TARGET_TEMP_LOW,
"climate_target_temperature_low_celsius",
"Target low temperature in degrees Celsius",
)
self._temperature_metric(
state,
- ATTR_CURRENT_TEMPERATURE,
+ ClimateEntityStateAttribute.CURRENT_TEMPERATURE,
"climate_current_temperature_celsius",
"Current temperature in degrees Celsius",
)
@@ -871,7 +857,7 @@ class PrometheusMetrics:
self._enum_metric(
state,
(
- (attr := state.attributes.get(ATTR_HVAC_ACTION))
+ (attr := state.attributes.get(ClimateEntityStateAttribute.HVAC_ACTION))
and getattr(attr, "value", attr)
),
[action.value for action in HVACAction],
@@ -882,23 +868,23 @@ class PrometheusMetrics:
self._enum_metric(
state,
state.state,
- state.attributes.get(ATTR_HVAC_MODES),
+ state.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES),
"climate_mode",
"HVAC mode",
"mode",
)
self._enum_metric(
state,
- state.attributes.get(ATTR_PRESET_MODE),
- state.attributes.get(ATTR_PRESET_MODES),
+ state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE),
+ state.attributes.get(ClimateEntityCapabilityAttribute.PRESET_MODES),
"climate_preset_mode",
"Preset mode enum",
"mode",
)
self._enum_metric(
state,
- state.attributes.get(ATTR_FAN_MODE),
- state.attributes.get(ATTR_FAN_MODES),
+ state.attributes.get(ClimateEntityStateAttribute.FAN_MODE),
+ state.attributes.get(ClimateEntityCapabilityAttribute.FAN_MODES),
"climate_fan_mode",
"Fan mode enum",
"mode",
@@ -909,15 +895,15 @@ class PrometheusMetrics:
self._float_metric(
state,
- ATTR_HUMIDITY,
+ HumidifierEntityStateAttribute.HUMIDITY,
"humidifier_target_humidity_percent",
"Target Relative Humidity",
)
self._enum_metric(
state,
- state.attributes.get(ATTR_MODE),
- state.attributes.get(ATTR_AVAILABLE_MODES),
+ state.attributes.get(HumidifierEntityStateAttribute.MODE),
+ state.attributes.get(HumidifierEntityCapabilityAttribute.AVAILABLE_MODES),
"humidifier_mode",
"Humidifier Mode",
"mode",
@@ -927,44 +913,45 @@ class PrometheusMetrics:
# Temperatures
self._temperature_metric(
state,
- ATTR_TEMPERATURE,
+ WaterHeaterStateAttribute.TEMPERATURE,
"water_heater_temperature_celsius",
"Target temperature in degrees Celsius",
)
self._temperature_metric(
state,
- WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
+ WaterHeaterStateAttribute.CURRENT_TEMPERATURE,
"water_heater_current_temperature_celsius",
"Current temperature in degrees Celsius",
)
self._temperature_metric(
state,
- WATER_HEATER_ATTR_TARGET_TEMP_HIGH,
+ WaterHeaterStateAttribute.TARGET_TEMP_HIGH,
"water_heater_target_temperature_high_celsius",
"Target high temperature in degrees Celsius",
)
self._temperature_metric(
state,
- WATER_HEATER_ATTR_TARGET_TEMP_LOW,
+ WaterHeaterStateAttribute.TARGET_TEMP_LOW,
"water_heater_target_temperature_low_celsius",
"Target low temperature in degrees Celsius",
)
self._temperature_metric(
state,
- WATER_HEATER_ATTR_MIN_TEMP,
+ WaterHeaterCapabilityAttribute.MIN_TEMP,
"water_heater_min_temperature_celsius",
"Minimum allowed temperature in degrees Celsius",
)
self._temperature_metric(
state,
- WATER_HEATER_ATTR_MAX_TEMP,
+ WaterHeaterCapabilityAttribute.MAX_TEMP,
"water_heater_max_temperature_celsius",
"Maximum allowed temperature in degrees Celsius",
)
self._enum_metric(
state,
- state.attributes.get(WATER_HEATER_ATTR_OPERATION_MODE) or state.state,
- state.attributes.get(WATER_HEATER_ATTR_OPERATION_LIST),
+ state.attributes.get(WaterHeaterStateAttribute.OPERATION_MODE)
+ or state.state,
+ state.attributes.get(WaterHeaterCapabilityAttribute.OPERATION_LIST),
"water_heater_operation_mode",
"Water heater operation mode",
"mode",
@@ -973,7 +960,7 @@ class PrometheusMetrics:
# Away mode bool
self._bool_metric(
state,
- WATER_HEATER_ATTR_AWAY_MODE,
+ WaterHeaterStateAttribute.AWAY_MODE,
"water_heater_away_mode",
"Whether away mode is on (0/1)",
{STATE_ON},
@@ -986,29 +973,32 @@ class PrometheusMetrics:
def _handle_fan(self, state: State) -> None:
self._numeric_metric(state, "fan", "fan")
self._float_metric(
- state, ATTR_PERCENTAGE, "fan_speed_percent", "Fan speed percent (0-100)"
+ state,
+ FanEntityStateAttribute.PERCENTAGE,
+ "fan_speed_percent",
+ "Fan speed percent (0-100)",
)
self._bool_metric(
state,
- ATTR_OSCILLATING,
+ FanEntityStateAttribute.OSCILLATING,
"fan_is_oscillating",
"Whether the fan is oscillating (0/1)",
)
self._enum_metric(
state,
- state.attributes.get(ATTR_PRESET_MODE),
- state.attributes.get(ATTR_PRESET_MODES),
+ state.attributes.get(FanEntityStateAttribute.PRESET_MODE),
+ state.attributes.get(FanEntityCapabilityAttribute.PRESET_MODES),
"fan_preset_mode",
"Fan preset mode enum",
"mode",
)
- fan_direction = state.attributes.get(ATTR_DIRECTION)
+ fan_direction = state.attributes.get(FanEntityStateAttribute.DIRECTION)
if fan_direction in {DIRECTION_FORWARD, DIRECTION_REVERSE}:
self._bool_metric(
state,
- ATTR_DIRECTION,
+ FanEntityStateAttribute.DIRECTION,
"fan_direction_reversed",
"Fan direction reversed (bool)",
{DIRECTION_REVERSE},
@@ -1050,7 +1040,9 @@ class PrometheusMetrics:
)
def _handle_sensor(self, state: State) -> None:
- unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
+ unit = self._unit_string(
+ state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ )
for metric_handler in self._sensor_metric_handlers:
metric = metric_handler(state, unit)
@@ -1063,7 +1055,7 @@ class PrometheusMetrics:
documentation = f"Sensor data measured in {unit}"
if (
- state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
== UnitOfTemperature.FAHRENHEIT
):
value = TemperatureConverter.convert(
@@ -1085,7 +1077,7 @@ class PrometheusMetrics:
@staticmethod
def _sensor_attribute_metric(state: State, unit: str | None) -> str | None:
"""Get metric based on device class attribute."""
- metric = state.attributes.get(ATTR_DEVICE_CLASS)
+ metric = state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if metric is not None:
return f"sensor_{metric}_{unit}"
return None
@@ -1096,7 +1088,7 @@ class PrometheusMetrics:
These have no unit of measurement attribute.
"""
- metric = state.attributes.get(ATTR_DEVICE_CLASS)
+ metric = state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if metric == SensorDeviceClass.TIMESTAMP:
return f"sensor_{metric}_seconds"
return None
diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py
index 6be9b1db7156..bd409a0889a6 100644
--- a/homeassistant/components/proximity/coordinator.py
+++ b/homeassistant/components/proximity/coordinator.py
@@ -5,17 +5,26 @@ from dataclasses import dataclass
import logging
from typing import cast, override
-from homeassistant.components.device_tracker import ATTR_IN_ZONES
-from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN, ENTITY_ID_HOME
+from homeassistant.components.device_tracker import (
+ DOMAIN as DEVICE_TRACKER_DOMAIN,
+ DeviceTrackerEntityStateAttribute,
+)
+from homeassistant.components.person import (
+ DOMAIN as PERSON_DOMAIN,
+ PersonEntityStateAttribute,
+)
+from homeassistant.components.zone import (
+ DOMAIN as ZONE_DOMAIN,
+ ENTITY_ID_HOME,
+ ZoneEntityStateAttribute,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_FRIENDLY_NAME,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
ATTR_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_ZONE,
STATE_HOME,
+ EntityStateAttribute,
)
from homeassistant.core import (
Event,
@@ -48,6 +57,19 @@ _LOGGER = logging.getLogger(__name__)
type ProximityConfigEntry = ConfigEntry[ProximityDataUpdateCoordinator]
+def _tracked_in_zones(state: State) -> list[str] | None:
+ """Return the zone membership of a tracked entity state.
+
+ Only person and device_tracker entities report zone membership; each
+ exposes it under its own platform enum. Any other domain returns None.
+ """
+ if state.domain == PERSON_DOMAIN:
+ return state.attributes.get(PersonEntityStateAttribute.IN_ZONES)
+ if state.domain == DEVICE_TRACKER_DOMAIN:
+ return state.attributes.get(DeviceTrackerEntityStateAttribute.IN_ZONES)
+ return None
+
+
@dataclass
class StateChangedData:
"""StateChangedData class."""
@@ -143,7 +165,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
},
)
- def _device_in_zone(self, zone: State, device: State) -> bool:
+ def _tracked_entity_in_zone(self, zone: State, tracked_entity_state: State) -> bool:
"""Return whether the tracked entity is currently in the proximity zone."""
# Modern entity-based trackers and person entities always report zone
@@ -160,35 +182,38 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
# (deprecated, removed in HA Core 2027.7).
# For both, an empty or absent ``in_zones`` does not imply "in no zone", so we
- # fall back to matching the device state against the zone's friendly name
- # (what a tracker's state is set to for non-home zones), plus an explicit
+ # fall back to matching the tracked entity state against the zone's friendly name
+ # (what a tracked entity's state is set to for non-home zones), plus an explicit
# home-zone check. Once both deprecations are gone, ``in_zones`` is
- # authoritative for every tracker and this method should reduce to the
+ # authoritative for every tracked entity and this method should reduce to the
# membership check alone; the fallback must be removed, as second-guessing an
# empty list would then be incorrect.
- if in_zones := device.attributes.get(ATTR_IN_ZONES):
+ if in_zones := _tracked_in_zones(tracked_entity_state):
return zone.entity_id in in_zones
# Remove once legacy device trackers (2027.5) and location_name (2027.7)
# are gone, see detailed comment above
- zone_friendly_name = zone.attributes.get(ATTR_FRIENDLY_NAME)
+ zone_friendly_name = zone.attributes.get(EntityStateAttribute.FRIENDLY_NAME)
return (
zone_friendly_name is not None
- and device.state.lower() == zone_friendly_name.lower()
- ) or (device.state == STATE_HOME and zone.entity_id == ENTITY_ID_HOME)
+ and tracked_entity_state.state.lower() == zone_friendly_name.lower()
+ ) or (
+ tracked_entity_state.state == STATE_HOME
+ and zone.entity_id == ENTITY_ID_HOME
+ )
def _calc_distance_to_zone(
self,
zone: State,
- device: State,
+ tracked_entity_state: State,
latitude: float | None,
longitude: float | None,
) -> int | None:
- if self._device_in_zone(zone, device):
+ if self._tracked_entity_in_zone(zone, tracked_entity_state):
_LOGGER.debug(
"%s: %s in zone -> distance=0",
self.name,
- device.entity_id,
+ tracked_entity_state.entity_id,
)
return 0
@@ -196,13 +221,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
_LOGGER.debug(
"%s: %s has no coordinates -> distance=None",
self.name,
- device.entity_id,
+ tracked_entity_state.entity_id,
)
return None
distance_to_centre = distance(
- zone.attributes[ATTR_LATITUDE],
- zone.attributes[ATTR_LONGITUDE],
+ zone.attributes[EntityStateAttribute.LATITUDE],
+ zone.attributes[EntityStateAttribute.LONGITUDE],
latitude,
longitude,
)
@@ -211,7 +236,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
# since zones must have lat/lon coordinates
assert distance_to_centre is not None
- zone_radius: float = zone.attributes["radius"]
+ zone_radius: float = zone.attributes[ZoneEntityStateAttribute.RADIUS]
if zone_radius > distance_to_centre:
# we've arrived the zone
return 0
@@ -220,17 +245,17 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
def _calc_direction_of_travel(
self,
zone: State,
- device: State,
+ tracked_entity_state: State,
old_latitude: float | None,
old_longitude: float | None,
new_latitude: float | None,
new_longitude: float | None,
) -> str | None:
- if self._device_in_zone(zone, device):
+ if self._tracked_entity_in_zone(zone, tracked_entity_state):
_LOGGER.debug(
"%s: %s in zone -> direction_of_travel=arrived",
self.name,
- device.entity_id,
+ tracked_entity_state.entity_id,
)
return "arrived"
@@ -243,14 +268,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
return None
old_distance = distance(
- zone.attributes[ATTR_LATITUDE],
- zone.attributes[ATTR_LONGITUDE],
+ zone.attributes[EntityStateAttribute.LATITUDE],
+ zone.attributes[EntityStateAttribute.LONGITUDE],
old_latitude,
old_longitude,
)
new_distance = distance(
- zone.attributes[ATTR_LATITUDE],
- zone.attributes[ATTR_LONGITUDE],
+ zone.attributes[EntityStateAttribute.LATITUDE],
+ zone.attributes[EntityStateAttribute.LONGITUDE],
new_latitude,
new_longitude,
)
@@ -306,8 +331,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
entities_data[entity_id][ATTR_DIST_TO] = self._calc_distance_to_zone(
zone_state,
tracked_entity_state,
- tracked_entity_state.attributes.get(ATTR_LATITUDE),
- tracked_entity_state.attributes.get(ATTR_LONGITUDE),
+ tracked_entity_state.attributes.get(EntityStateAttribute.LATITUDE),
+ tracked_entity_state.attributes.get(EntityStateAttribute.LONGITUDE),
)
if entities_data[entity_id][ATTR_DIST_TO] is None:
_LOGGER.debug(
@@ -328,8 +353,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
)
if (old_state := state_change_data.old_state) is not None:
- old_lat = old_state.attributes.get(ATTR_LATITUDE)
- old_lon = old_state.attributes.get(ATTR_LONGITUDE)
+ old_lat = old_state.attributes.get(EntityStateAttribute.LATITUDE)
+ old_lon = old_state.attributes.get(EntityStateAttribute.LONGITUDE)
else:
old_lat = None
old_lon = None
@@ -340,8 +365,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
new_state,
old_lat,
old_lon,
- new_state.attributes.get(ATTR_LATITUDE),
- new_state.attributes.get(ATTR_LONGITUDE),
+ new_state.attributes.get(EntityStateAttribute.LATITUDE),
+ new_state.attributes.get(EntityStateAttribute.LONGITUDE),
)
)
diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py
index c304b4822f37..a5e4d179bcec 100644
--- a/homeassistant/components/proximity/diagnostics.py
+++ b/homeassistant/components/proximity/diagnostics.py
@@ -7,12 +7,11 @@ from homeassistant.components.diagnostics import REDACTED, async_redact_data
from homeassistant.components.person import ATTR_USER_ID
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
STATE_HOME,
STATE_NOT_HOME,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant
@@ -21,8 +20,8 @@ from .coordinator import ProximityConfigEntry
TO_REDACT = {
ATTR_GPS,
ATTR_IP,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
+ EntityStateAttribute.LATITUDE,
+ EntityStateAttribute.LONGITUDE,
ATTR_MAC,
ATTR_USER_ID,
"context",
diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py
index 2f969fae5bed..291a6c298e31 100644
--- a/homeassistant/components/proxmoxve/__init__.py
+++ b/homeassistant/components/proxmoxve/__init__.py
@@ -2,43 +2,17 @@
import logging
-import voluptuous as vol
-
-from homeassistant.config_entries import SOURCE_IMPORT
-from homeassistant.const import (
- CONF_HOST,
- CONF_PASSWORD,
- CONF_PORT,
- CONF_TOKEN,
- CONF_USERNAME,
- CONF_VERIFY_SSL,
- Platform,
-)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import (
- config_validation as cv,
- entity_registry as er,
- issue_registry as ir,
-)
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
from .const import (
AUTH_OTHER,
AUTH_PAM,
AUTH_PVE,
CONF_AUTH_METHOD,
- CONF_CONTAINERS,
- CONF_NODE,
- CONF_NODES,
CONF_REALM,
- CONF_TOKEN_ID,
- CONF_TOKEN_SECRET,
- CONF_VMS,
- DEFAULT_PORT,
DEFAULT_REALM,
- DEFAULT_VERIFY_SSL,
- DOMAIN,
)
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator
@@ -49,107 +23,9 @@ PLATFORMS = [
]
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.All(
- cv.ensure_list,
- [
- vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Required(
- CONF_AUTH_METHOD, default=DEFAULT_REALM
- ): cv.string,
- vol.Optional(CONF_REALM, default=DEFAULT_REALM): cv.string,
- vol.Optional(CONF_TOKEN, default=False): cv.boolean,
- vol.Optional(CONF_TOKEN_ID): cv.string,
- vol.Optional(CONF_TOKEN_SECRET): cv.string,
- vol.Optional(
- CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
- ): cv.boolean,
- vol.Required(CONF_NODES): vol.All(
- cv.ensure_list,
- [
- vol.Schema(
- {
- vol.Required(CONF_NODE): cv.string,
- vol.Optional(CONF_VMS, default=[]): [
- cv.positive_int
- ],
- vol.Optional(CONF_CONTAINERS, default=[]): [
- cv.positive_int
- ],
- }
- )
- ],
- ),
- }
- )
- ],
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
_LOGGER = logging.getLogger(__name__)
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Import the Proxmox configuration from YAML."""
- if DOMAIN not in config:
- return True
-
- hass.async_create_task(_async_setup(hass, config))
-
- return True
-
-
-async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
- for entry_config in config[DOMAIN]:
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=entry_config,
- )
- if (
- result.get("type") is FlowResultType.ABORT
- and result.get("reason") != "already_configured"
- ):
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{result.get('reason')}",
- breaks_in_ha_version="2026.8.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=ir.IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Proxmox VE",
- },
- )
- return
-
- ir.async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- "deprecated_yaml",
- breaks_in_ha_version="2026.8.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=ir.IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Proxmox VE",
- },
- )
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool:
"""Set up a ProxmoxVE from a config entry."""
coordinator = ProxmoxCoordinator(hass, entry)
diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py
index 1dba1d6985ed..69f814a97c11 100644
--- a/homeassistant/components/proxmoxve/binary_sensor.py
+++ b/homeassistant/components/proxmoxve/binary_sensor.py
@@ -20,6 +20,7 @@ from .const import (
STORAGE_ENABLED,
STORAGE_SHARED,
VM_CONTAINER_RUNNING,
+ ProxmoxPermission,
)
from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData
from .entity import (
@@ -28,6 +29,7 @@ from .entity import (
ProxmoxStorageEntity,
ProxmoxVMEntity,
)
+from .helpers import is_granted
PARALLEL_UPDATES = 0
@@ -51,6 +53,8 @@ class ProxmoxNodeBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class to hold Proxmox node binary sensor description."""
state_fn: Callable[[ProxmoxNodeData], bool | None]
+ permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT
+ permission_target: str = "nodes"
@dataclass(frozen=True, kw_only=True)
@@ -67,6 +71,8 @@ NODE_SENSORS: tuple[ProxmoxNodeBinarySensorEntityDescription, ...] = (
state_fn=lambda data: data.node["status"] == NODE_ONLINE,
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
+ permission=ProxmoxPermission.VMAUDIT, # PVEVMUsers are allowed this node, through "/vms"
+ permission_target="vms",
),
ProxmoxNodeBinarySensorEntityDescription(
key="node_backup_status",
@@ -132,10 +138,17 @@ async def async_setup_entry(
def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None:
"""Add new node binary sensors."""
+
async_add_entities(
ProxmoxNodeBinarySensor(coordinator, entity_description, node)
for node in nodes
for entity_description in NODE_SENSORS
+ if is_granted(
+ coordinator.permissions,
+ p_type=entity_description.permission_target,
+ p_id=node.node["node"],
+ permission=entity_description.permission,
+ )
)
def _async_add_new_vms(
diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py
index e18a9922685d..b93e455dccab 100644
--- a/homeassistant/components/proxmoxve/button.py
+++ b/homeassistant/components/proxmoxve/button.py
@@ -17,7 +17,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
@@ -28,8 +28,6 @@ from .helpers import is_granted
PARALLEL_UPDATES = 1
-NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power"
-
@dataclass(frozen=True, kw_only=True)
class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription):
@@ -37,7 +35,6 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription):
press_action: Callable[[ProxmoxCoordinator, str], None]
permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER
- permission_raise: str = "no_permission_node_power"
permission_target: str = "nodes"
@@ -47,7 +44,6 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription):
press_action: Callable[[ProxmoxCoordinator, str, int], None]
permission: ProxmoxPermission = ProxmoxPermission.POWER
- permission_raise: str = NO_PERM_VM_LXC_POWER
permission_target: str = "vms"
@@ -57,7 +53,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription):
press_action: Callable[[ProxmoxCoordinator, str, int], None]
permission: ProxmoxPermission = ProxmoxPermission.POWER
- permission_raise: str = NO_PERM_VM_LXC_POWER
permission_target: str = "vms"
@@ -82,7 +77,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
key="start_all",
translation_key="start_all",
permission=ProxmoxPermission.POWER,
- permission_raise=NO_PERM_VM_LXC_POWER,
permission_target="vms",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
@@ -93,7 +87,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
key="stop_all",
translation_key="stop_all",
permission=ProxmoxPermission.POWER,
- permission_raise=NO_PERM_VM_LXC_POWER,
permission_target="vms",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
@@ -104,7 +97,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
key="suspend_all",
translation_key="suspend_all",
permission=ProxmoxPermission.POWER,
- permission_raise=NO_PERM_VM_LXC_POWER,
permission_target="vms",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
@@ -185,7 +177,6 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
)
),
permission=ProxmoxPermission.SNAPSHOT,
- permission_raise="no_permission_snapshot",
entity_category=EntityCategory.CONFIG,
),
)
@@ -230,7 +221,6 @@ CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (
)
),
permission=ProxmoxPermission.SNAPSHOT,
- permission_raise="no_permission_snapshot",
entity_category=EntityCategory.CONFIG,
),
)
@@ -250,6 +240,12 @@ async def async_setup_entry(
ProxmoxNodeButtonEntity(coordinator, entity_description, node)
for node in nodes
for entity_description in NODE_BUTTONS
+ if is_granted(
+ coordinator.permissions,
+ p_type=entity_description.permission_target,
+ p_id=node.node["node"],
+ permission=entity_description.permission,
+ )
)
def _async_add_new_vms(
@@ -260,6 +256,12 @@ async def async_setup_entry(
ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data)
for (node_data, vm) in vms
for entity_description in VM_BUTTONS
+ if is_granted(
+ coordinator.permissions,
+ p_type=entity_description.permission_target,
+ p_id=vm["vmid"],
+ permission=entity_description.permission,
+ )
)
def _async_add_new_containers(
@@ -272,6 +274,12 @@ async def async_setup_entry(
)
for (node_data, container) in containers
for entity_description in CONTAINER_BUTTONS
+ if is_granted(
+ coordinator.permissions,
+ p_type=entity_description.permission_target,
+ p_id=container["vmid"],
+ permission=entity_description.permission,
+ )
)
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
@@ -324,22 +332,22 @@ class ProxmoxBaseButton(ButtonEntity):
except AuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="cannot_connect_no_details",
+ translation_key="cannot_connect",
) from err
except SSLError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="invalid_auth_no_details",
+ translation_key="invalid_auth",
) from err
except ConnectTimeout as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="timeout_connect_no_details",
+ translation_key="timeout_connect",
) from err
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="api_error_no_details",
+ translation_key="api_error_details",
) from err
@@ -351,21 +359,10 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton):
@override
async def _async_press_call(self) -> None:
"""Execute the node button action via executor."""
- node_id = self._node_data.node["node"]
- if not is_granted(
- self.coordinator.permissions,
- p_type=self.entity_description.permission_target,
- p_id=node_id,
- permission=self.entity_description.permission,
- ):
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key=self.entity_description.permission_raise,
- )
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
- node_id,
+ self._node_data.node["node"],
)
@@ -377,22 +374,11 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton):
@override
async def _async_press_call(self) -> None:
"""Execute the VM button action via executor."""
- vmid = self.vm_data["vmid"]
- if not is_granted(
- self.coordinator.permissions,
- p_type=self.entity_description.permission_target,
- p_id=vmid,
- permission=self.entity_description.permission,
- ):
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key=self.entity_description.permission_raise,
- )
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
self._node_name,
- vmid,
+ self.vm_data["vmid"],
)
@@ -404,21 +390,9 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton):
@override
async def _async_press_call(self) -> None:
"""Execute the container button action via executor."""
- vmid = self.container_data["vmid"]
- # Container power actions fall under vms
- if not is_granted(
- self.coordinator.permissions,
- p_type=self.entity_description.permission_target,
- p_id=vmid,
- permission=self.entity_description.permission,
- ):
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key=self.entity_description.permission_raise,
- )
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
self._node_name,
- vmid,
+ self.container_data["vmid"],
)
diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py
index 565c37300fe5..14c4086f6a61 100644
--- a/homeassistant/components/proxmoxve/config_flow.py
+++ b/homeassistant/components/proxmoxve/config_flow.py
@@ -356,30 +356,6 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
return proxmox_nodes, errors
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Handle a flow initiated by configuration file."""
- self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
-
- try:
- proxmox_nodes = await self.hass.async_add_executor_job(
- _get_nodes_data, import_data
- )
- except ProxmoxConnectTimeout:
- return self.async_abort(reason="connect_timeout")
- except ProxmoxAuthenticationError:
- return self.async_abort(reason="invalid_auth")
- except ProxmoxSSLError:
- return self.async_abort(reason="ssl_error")
- except ProxmoxNoNodesFound:
- return self.async_abort(reason="no_nodes_found")
- except ProxmoxConnectionError:
- return self.async_abort(reason="cannot_connect")
-
- return self.async_create_entry(
- title=import_data[CONF_HOST],
- data={**import_data, CONF_NODES: proxmox_nodes},
- )
-
def _get_auth_schema(
self,
data: Mapping[str, Any],
diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py
index bfd944612a0d..8985a2a77ec9 100644
--- a/homeassistant/components/proxmoxve/const.py
+++ b/homeassistant/components/proxmoxve/const.py
@@ -41,4 +41,6 @@ class ProxmoxPermission(StrEnum):
POWER = "VM.PowerMgmt"
SNAPSHOT = "VM.Snapshot"
+ SYSAUDIT = "Sys.Audit"
SYSPOWER = "Sys.PowerMgmt"
+ VMAUDIT = "VM.Audit"
diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py
index e9cfffc16f6b..09b04b21d7a1 100644
--- a/homeassistant/components/proxmoxve/coordinator.py
+++ b/homeassistant/components/proxmoxve/coordinator.py
@@ -110,25 +110,21 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
- translation_placeholders={"error": repr(err)},
) from err
except SSLError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="ssl_error",
- translation_placeholders={"error": repr(err)},
) from err
except ConnectTimeout as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
- translation_placeholders={"error": repr(err)},
) from err
except ProxmoxServerError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error_details",
- translation_placeholders={"error": repr(err)},
) from err
except ProxmoxPermissionsError as err:
raise ConfigEntryAuthFailed(
@@ -144,7 +140,6 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
- translation_placeholders={"error": repr(err)},
) from err
@override
@@ -157,19 +152,16 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
- translation_placeholders={"error": repr(err)},
) from err
except SSLError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="ssl_error",
- translation_placeholders={"error": repr(err)},
) from err
except ConnectTimeout as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
- translation_placeholders={"error": repr(err)},
) from err
except ResourceException as err:
raise UpdateFailed(
@@ -180,7 +172,6 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
- translation_placeholders={"error": repr(err)},
) from err
data: dict[str, ProxmoxNodeData] = {}
@@ -363,9 +354,7 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
for identifier in device.identifiers
):
_LOGGER.debug("Removing stale device: %s", device.identifiers)
- registry.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ registry.async_remove_device(device.id)
class ProxmoxSetupError(Exception):
diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py
index a886b5af24cc..d4140fc13d5e 100644
--- a/homeassistant/components/proxmoxve/sensor.py
+++ b/homeassistant/components/proxmoxve/sensor.py
@@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
+from .const import ProxmoxPermission
from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData
from .entity import (
ProxmoxContainerEntity,
@@ -25,6 +26,7 @@ from .entity import (
ProxmoxStorageEntity,
ProxmoxVMEntity,
)
+from .helpers import is_granted
PARALLEL_UPDATES = 0
@@ -34,6 +36,8 @@ class ProxmoxNodeSensorEntityDescription(SensorEntityDescription):
"""Class to hold Proxmox node sensor description."""
value_fn: Callable[[ProxmoxNodeData], StateType | datetime]
+ permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT
+ permission_target: str = "nodes"
@dataclass(frozen=True, kw_only=True)
@@ -147,6 +151,8 @@ NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = (
value_fn=lambda data: data.node["status"],
device_class=SensorDeviceClass.ENUM,
options=["online", "offline"],
+ permission=ProxmoxPermission.VMAUDIT,
+ permission_target="vms",
),
ProxmoxNodeSensorEntityDescription(
key="node_backup_last_backup",
@@ -230,7 +236,7 @@ VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = (
ProxmoxVMSensorEntityDescription(
key="vm_uptime",
translation_key="vm_uptime",
- value_fn=lambda data: data["uptime"],
+ value_fn=lambda data: data.get("uptime"),
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
@@ -347,7 +353,7 @@ CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = (
ProxmoxContainerSensorEntityDescription(
key="container_uptime",
translation_key="container_uptime",
- value_fn=lambda data: data["uptime"],
+ value_fn=lambda data: data.get("uptime"),
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
@@ -474,6 +480,12 @@ async def async_setup_entry(
ProxmoxNodeSensor(coordinator, entity_description, node)
for node in nodes
for entity_description in NODE_SENSORS
+ if is_granted(
+ coordinator.permissions,
+ p_type=entity_description.permission_target,
+ p_id=node.node["node"],
+ permission=entity_description.permission,
+ )
)
def _async_add_new_vms(
diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json
index a92e6ef4506f..904b88f894de 100644
--- a/homeassistant/components/proxmoxve/strings.json
+++ b/homeassistant/components/proxmoxve/strings.json
@@ -297,35 +297,17 @@
},
"exceptions": {
"api_error_details": {
- "message": "An error occurred while communicating with the Proxmox VE instance: {error}"
- },
- "api_error_no_details": {
"message": "An error occurred while communicating with the Proxmox VE instance."
},
"cannot_connect": {
- "message": "An error occurred while trying to connect to the Proxmox VE instance: {error}"
- },
- "cannot_connect_no_details": {
- "message": "Could not connect to the Proxmox VE instance."
+ "message": "An error occurred while trying to connect to the Proxmox VE instance."
},
"invalid_auth": {
- "message": "An error occurred while trying to authenticate: {error}"
- },
- "invalid_auth_no_details": {
- "message": "Authentication failed for the Proxmox VE instance."
+ "message": "An error occurred while trying to authenticate."
},
"no_nodes_found": {
"message": "No active nodes were found on the Proxmox VE server."
},
- "no_permission_node_power": {
- "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again."
- },
- "no_permission_snapshot": {
- "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again."
- },
- "no_permission_vm_lxc_power": {
- "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again."
- },
"no_vmlxc_found": {
"message": "No LXC or VM were found on the Proxmox VE server."
},
@@ -333,37 +315,12 @@
"message": "Failed to retrieve Proxmox VE permissions. Please check your credentials and try again."
},
"ssl_error": {
- "message": "An SSL error occurred: {error}"
+ "message": "An SSL error occurred."
},
"timeout_connect": {
- "message": "A timeout occurred while trying to connect to the Proxmox VE instance: {error}"
- },
- "timeout_connect_no_details": {
"message": "A timeout occurred while trying to connect to the Proxmox VE instance."
}
},
- "issues": {
- "deprecated_yaml_import_issue_cannot_connect": {
- "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
- "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
- },
- "deprecated_yaml_import_issue_connect_timeout": {
- "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
- "title": "The {integration_title} YAML configuration is being removed"
- },
- "deprecated_yaml_import_issue_invalid_auth": {
- "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
- "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
- },
- "deprecated_yaml_import_issue_no_nodes_found": {
- "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, no active nodes were found on the Proxmox VE server. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
- "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
- },
- "deprecated_yaml_import_issue_ssl_error": {
- "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
- "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
- }
- },
"selector": {
"auth_method": {
"options": {
diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json
index 4c89754f04f1..0bc3f6608cf3 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==12.2.0"]
+ "requirements": ["Pillow==12.3.0"]
}
diff --git a/homeassistant/components/ptdevices/__init__.py b/homeassistant/components/ptdevices/__init__.py
index 9a557749494e..00f8c28d8a86 100644
--- a/homeassistant/components/ptdevices/__init__.py
+++ b/homeassistant/components/ptdevices/__init__.py
@@ -11,6 +11,7 @@ from .const import DEFAULT_URL
from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator
_PLATFORMS: list[Platform] = [
+ Platform.BINARY_SENSOR,
Platform.SENSOR,
]
diff --git a/homeassistant/components/ptdevices/binary_sensor.py b/homeassistant/components/ptdevices/binary_sensor.py
new file mode 100644
index 000000000000..b3858200c171
--- /dev/null
+++ b/homeassistant/components/ptdevices/binary_sensor.py
@@ -0,0 +1,121 @@
+"""PTDevices Binary Sensors."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import StrEnum
+from typing import override
+
+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 homeassistant.helpers.typing import StateType
+
+from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator
+from .entity import PTDevicesEntity
+
+PARALLEL_UPDATES = 0
+
+
+class PTDevicesBinarySensors(StrEnum):
+ """Store keys for PTDevices binary sensors."""
+
+ DEVICE_BATTERY_STATUS = "battery_status"
+ DEVICE_EXTERNAL_POWER = "external_power"
+
+
+@dataclass(kw_only=True, frozen=True)
+class PTDevicesBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Description for PTDevices binary sensor entities."""
+
+ is_on_fn: Callable[[dict[str, StateType]], bool | None]
+
+
+BINARY_SENSOR_DESCRIPTIONS: tuple[PTDevicesBinarySensorEntityDescription, ...] = (
+ PTDevicesBinarySensorEntityDescription(
+ key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS,
+ translation_key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS,
+ device_class=BinarySensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda data: (
+ None
+ if data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS)
+ in (None, "unknown")
+ else data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) == "low"
+ ),
+ ),
+ PTDevicesBinarySensorEntityDescription(
+ key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER,
+ translation_key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER,
+ device_class=BinarySensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda data: (
+ bool(data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER))
+ if data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER) is not None
+ else None
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: PTDevicesConfigEntry,
+ async_add_entity: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Setup PTDevices binary sensors based on config entry."""
+ coordinator = config_entry.runtime_data
+
+ known_sensors: set[tuple[str, str]] = set()
+
+ def _check_device() -> None:
+ for device_id in sorted(coordinator.data):
+ device = coordinator.data[device_id]
+ new_sensors = [
+ sensor
+ for sensor in BINARY_SENSOR_DESCRIPTIONS
+ if sensor.key in device and (device_id, sensor.key) not in known_sensors
+ ]
+ if not new_sensors:
+ continue
+ known_sensors.update((device_id, sensor.key) for sensor in new_sensors)
+ async_add_entity(
+ PTDevicesBinarySensorEntity(
+ config_entry.runtime_data, sensor, device_id
+ )
+ for sensor in new_sensors
+ )
+
+ _check_device()
+ config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
+
+
+class PTDevicesBinarySensorEntity(PTDevicesEntity, BinarySensorEntity):
+ """Defines a PTDevices binary sensor."""
+
+ entity_description: PTDevicesBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PTDevicesCoordinator,
+ description: PTDevicesBinarySensorEntityDescription,
+ device_id: str,
+ ) -> None:
+ """Initialize sensor."""
+ super().__init__(
+ coordinator,
+ description.key,
+ device_id,
+ )
+
+ self.entity_description = description
+
+ @property
+ @override
+ def is_on(self) -> bool | None:
+ """Return the state of the sensor."""
+ return self.entity_description.is_on_fn(self.device)
diff --git a/homeassistant/components/ptdevices/coordinator.py b/homeassistant/components/ptdevices/coordinator.py
index 828034d089bd..6bb1b141610a 100644
--- a/homeassistant/components/ptdevices/coordinator.py
+++ b/homeassistant/components/ptdevices/coordinator.py
@@ -82,8 +82,6 @@ class PTDevicesCoordinator(DataUpdateCoordinator[PTDevicesResponseData]):
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing stale device entry %s", device.name)
- device_reg.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_reg.async_remove_device(device.id)
return data["body"]
diff --git a/homeassistant/components/ptdevices/strings.json b/homeassistant/components/ptdevices/strings.json
index 318c4fd1266d..9c5def4c87be 100644
--- a/homeassistant/components/ptdevices/strings.json
+++ b/homeassistant/components/ptdevices/strings.json
@@ -23,6 +23,11 @@
}
},
"entity": {
+ "binary_sensor": {
+ "external_power": {
+ "name": "External power"
+ }
+ },
"sensor": {
"battery_voltage": {
"name": "Battery voltage"
diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py
index 02ae40b645de..c6a7b198a63d 100644
--- a/homeassistant/components/purpleair/entity.py
+++ b/homeassistant/components/purpleair/entity.py
@@ -5,7 +5,7 @@ from typing import Any, override
from aiopurpleair.models.sensors import SensorModel
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP
+from homeassistant.const import CONF_SHOW_ON_MAP, EntityStateAttribute
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -43,15 +43,15 @@ class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]):
@override
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return entity specific state attributes."""
- attrs = {}
+ attrs: dict[str, Any] = {}
# Displaying the geography on the map relies upon putting the latitude/longitude
# in the entity attributes with "latitude" and "longitude" as the keys.
# Conversely, we can hide the location on the map by using other keys, like
# "lati" and "long":
if self._entry.options.get(CONF_SHOW_ON_MAP):
- attrs[ATTR_LATITUDE] = self.sensor_data.latitude
- attrs[ATTR_LONGITUDE] = self.sensor_data.longitude
+ attrs[EntityStateAttribute.LATITUDE] = self.sensor_data.latitude
+ attrs[EntityStateAttribute.LONGITUDE] = self.sensor_data.longitude
else:
attrs["lati"] = self.sensor_data.latitude
attrs["long"] = self.sensor_data.longitude
diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py
index 16e850f5e889..75187a83e090 100644
--- a/homeassistant/components/pushover/__init__.py
+++ b/homeassistant/components/pushover/__init__.py
@@ -1,4 +1,5 @@
"""The pushover component."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from pushover_complete import BadAPIRequestError, PushoverAPI
from requests.exceptions import RequestException
diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json
index 7d3e750f441e..f2b95947f546 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==12.2.0", "pyzbar==0.1.9"]
+ "requirements": ["Pillow==12.3.0", "pyzbar==0.1.9"]
}
diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py
index ab0886096cc7..90de274fb938 100644
--- a/homeassistant/components/rachio/__init__.py
+++ b/homeassistant/components/rachio/__init__.py
@@ -10,9 +10,12 @@ from homeassistant.components import cloud
from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
-from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS
+from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN
from .device import RachioConfigEntry, RachioPerson
+from .services import async_setup_services
from .webhooks import (
async_get_or_create_registered_webhook_id_and_url,
async_register_webhook,
@@ -23,6 +26,14 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SWITCH]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Rachio integration."""
+ async_setup_services(hass)
+ return True
+
async def async_unload_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> bool:
"""Unload a config entry."""
diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py
index 919f323029aa..5bf10c08f23f 100644
--- a/homeassistant/components/rachio/device.py
+++ b/homeassistant/components/rachio/device.py
@@ -5,16 +5,13 @@ import logging
from typing import Any, override
from rachiopy import Rachio
-import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv
from .const import (
- DOMAIN,
KEY_BASE_STATIONS,
KEY_DEVICES,
KEY_ENABLED,
@@ -30,31 +27,14 @@ from .const import (
KEY_USERNAME,
KEY_ZONES,
LISTEN_EVENT_TYPES,
- MODEL_GENERATION_1,
- SERVICE_PAUSE_WATERING,
- SERVICE_RESUME_WATERING,
- SERVICE_STOP_WATERING,
WEBHOOK_CONST_ID,
)
from .coordinator import RachioScheduleUpdateCoordinator, RachioUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
-ATTR_DEVICES = "devices"
-ATTR_DURATION = "duration"
PERMISSION_ERROR = "7"
-PAUSE_SERVICE_SCHEMA = vol.Schema(
- {
- vol.Optional(ATTR_DEVICES): cv.string,
- vol.Optional(ATTR_DURATION, default=60): cv.positive_int,
- }
-)
-
-RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string})
-
-STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string})
-
type RachioConfigEntry = ConfigEntry[RachioPerson]
@@ -72,66 +52,8 @@ class RachioPerson:
self._base_stations: list[RachioBaseStation] = []
async def async_setup(self, hass: HomeAssistant) -> None:
- """Create rachio devices and services."""
+ """Create rachio devices."""
await hass.async_add_executor_job(self._setup, hass)
- can_pause = False
- for rachio_iro in self._controllers:
- # Generation 1 controllers don't support pause or resume
- if rachio_iro.model.split("_")[0] != MODEL_GENERATION_1:
- can_pause = True
- break
-
- all_controllers = [rachio_iro.name for rachio_iro in self._controllers]
-
- def pause_water(service: ServiceCall) -> None:
- """Service to pause watering on all or specific controllers."""
- duration = service.data[ATTR_DURATION]
- devices = service.data.get(ATTR_DEVICES, all_controllers)
- for iro in self._controllers:
- if iro.name in devices:
- iro.pause_watering(duration)
-
- def resume_water(service: ServiceCall) -> None:
- """Service to resume watering on all or specific controllers."""
- devices = service.data.get(ATTR_DEVICES, all_controllers)
- for iro in self._controllers:
- if iro.name in devices:
- iro.resume_watering()
-
- def stop_water(service: ServiceCall) -> None:
- """Service to stop watering on all or specific controllers."""
- devices = service.data.get(ATTR_DEVICES, all_controllers)
- for iro in self._controllers:
- if iro.name in devices:
- iro.stop_watering()
-
- # If only hose timers on account, none of these services apply
- if not all_controllers:
- return
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_STOP_WATERING,
- stop_water,
- schema=STOP_SERVICE_SCHEMA,
- )
-
- if not can_pause:
- return
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_PAUSE_WATERING,
- pause_water,
- schema=PAUSE_SERVICE_SCHEMA,
- )
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_RESUME_WATERING,
- resume_water,
- schema=RESUME_SERVICE_SCHEMA,
- )
def _setup(self, hass: HomeAssistant) -> None:
"""Rachio device setup."""
diff --git a/homeassistant/components/rachio/services.py b/homeassistant/components/rachio/services.py
new file mode 100644
index 000000000000..1e30e85e4645
--- /dev/null
+++ b/homeassistant/components/rachio/services.py
@@ -0,0 +1,135 @@
+"""Services for the Rachio integration."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID, Platform
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import (
+ config_validation as cv,
+ entity_registry as er,
+ service,
+)
+
+from .const import (
+ DOMAIN,
+ KEY_ID,
+ MODEL_GENERATION_1,
+ SERVICE_PAUSE_WATERING,
+ SERVICE_RESUME_WATERING,
+ SERVICE_START_MULTIPLE_ZONES,
+ SERVICE_STOP_WATERING,
+)
+from .device import RachioConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DEVICES = "devices"
+ATTR_DURATION = "duration"
+ATTR_SORT_ORDER = "sortOrder"
+
+PAUSE_SERVICE_SCHEMA = vol.Schema(
+ {
+ vol.Optional(ATTR_DEVICES): cv.string,
+ vol.Optional(ATTR_DURATION, default=60): cv.positive_int,
+ }
+)
+
+RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string})
+
+START_MULTIPLE_ZONES_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_DURATION): cv.ensure_list_csv,
+ }
+)
+
+STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string})
+
+
+def _stop_water(call: ServiceCall) -> None:
+ """Stop watering on all or specific controllers."""
+ entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None)
+ person = entry.runtime_data
+ devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers])
+ for iro in person.controllers:
+ if iro.name in devices:
+ iro.stop_watering()
+
+
+def _pause_water(call: ServiceCall) -> None:
+ """Pause watering on all or specific controllers."""
+ entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None)
+ person = entry.runtime_data
+ devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers])
+ for iro in person.controllers:
+ if iro.name in devices and iro.model.split("_")[0] != MODEL_GENERATION_1:
+ iro.pause_watering(call.data[ATTR_DURATION])
+
+
+def _resume_water(call: ServiceCall) -> None:
+ """Resume watering on all or specific controllers."""
+ entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None)
+ person = entry.runtime_data
+ devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers])
+ for iro in person.controllers:
+ if iro.name in devices and iro.model.split("_")[0] != MODEL_GENERATION_1:
+ iro.resume_watering()
+
+
+def _start_multiple(call: ServiceCall) -> None:
+ """Start multiple zones in sequence."""
+ entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None)
+ person = entry.runtime_data
+ entity_reg = er.async_get(call.hass)
+ duration = iter(call.data[ATTR_DURATION])
+ default_time = call.data[ATTR_DURATION][0]
+
+ entity_to_zone_id = {
+ entity_reg.async_get_entity_id(
+ Platform.SWITCH,
+ DOMAIN,
+ f"{controller.controller_id}-zone-{zone[KEY_ID]}",
+ ): zone[KEY_ID]
+ for controller in person.controllers
+ for zone in controller.list_zones()
+ }
+
+ zones_list = [
+ {
+ ATTR_ID: entity_to_zone_id[entity_id],
+ ATTR_DURATION: int(next(duration, default_time)) * 60,
+ ATTR_SORT_ORDER: count,
+ }
+ for count, entity_id in enumerate(call.data[ATTR_ENTITY_ID])
+ if entity_id in entity_to_zone_id
+ ]
+
+ if not zones_list:
+ raise HomeAssistantError("No matching zones found in given entity_ids")
+
+ person.start_multiple_zones(zones_list)
+ _LOGGER.debug("Starting zone(s) %s", call.data[ATTR_ENTITY_ID])
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register Rachio services."""
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_STOP_WATERING, _stop_water, schema=STOP_SERVICE_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_PAUSE_WATERING, _pause_water, schema=PAUSE_SERVICE_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_RESUME_WATERING, _resume_water, schema=RESUME_SERVICE_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_START_MULTIPLE_ZONES,
+ _start_multiple,
+ schema=START_MULTIPLE_ZONES_SCHEMA,
+ )
diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py
index 146618fad2be..83025aa03c85 100644
--- a/homeassistant/components/rachio/switch.py
+++ b/homeassistant/components/rachio/switch.py
@@ -9,9 +9,7 @@ from typing import Any, override
import voluptuous as vol
from homeassistant.components.switch import SwitchEntity
-from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -22,7 +20,6 @@ from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_ti
from .const import (
CONF_MANUAL_RUN_MINS,
DEFAULT_MANUAL_RUN_MINS,
- DOMAIN,
KEY_CURRENT_STATUS,
KEY_CUSTOM_CROP,
KEY_CUSTOM_SHADE,
@@ -45,7 +42,6 @@ from .const import (
SCHEDULE_TYPE_FIXED,
SCHEDULE_TYPE_FLEX,
SERVICE_SET_ZONE_MOISTURE,
- SERVICE_START_MULTIPLE_ZONES,
SERVICE_START_WATERING,
SIGNAL_RACHIO_CONTROLLER_UPDATE,
SIGNAL_RACHIO_RAIN_DELAY_UPDATE,
@@ -80,7 +76,6 @@ ATTR_SCHEDULE_SUMMARY = "Summary"
ATTR_SCHEDULE_ENABLED = "Enabled"
ATTR_SCHEDULE_DURATION = "Duration"
ATTR_SCHEDULE_TYPE = "Type"
-ATTR_SORT_ORDER = "sortOrder"
ATTR_WATERING_DURATION = "Watering Duration seconds"
ATTR_ZONE_NUMBER = "Zone number"
ATTR_ZONE_SHADE = "Shade"
@@ -88,13 +83,6 @@ ATTR_ZONE_SLOPE = "Slope"
ATTR_ZONE_SUMMARY = "Summary"
ATTR_ZONE_TYPE = "Type"
-START_MULTIPLE_ZONES_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_DURATION): cv.ensure_list_csv,
- }
-)
-
async def async_setup_entry(
hass: HomeAssistant,
@@ -102,47 +90,14 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Rachio switches."""
- zone_entities = []
has_flex_sched = False
entities = await hass.async_add_executor_job(_create_entities, hass, config_entry)
for entity in entities:
- if isinstance(entity, RachioZone):
- zone_entities.append(entity)
if isinstance(entity, RachioSchedule) and entity.type == SCHEDULE_TYPE_FLEX:
has_flex_sched = True
async_add_entities(entities)
- def start_multiple(service: ServiceCall) -> None:
- """Service to start multiple zones in sequence."""
- zones_list = []
- person = config_entry.runtime_data
- entity_id = service.data[ATTR_ENTITY_ID]
- duration = iter(service.data[ATTR_DURATION])
- default_time = service.data[ATTR_DURATION][0]
- entity_to_zone_id = {
- entity.entity_id: entity.zone_id for entity in zone_entities
- }
-
- for count, data in enumerate(entity_id):
- if data in entity_to_zone_id:
- # Time can be passed as a list per zone,
- # or one time for all zones
- time = int(next(duration, default_time)) * 60
- zones_list.append(
- {
- ATTR_ID: entity_to_zone_id.get(data),
- ATTR_DURATION: time,
- ATTR_SORT_ORDER: count,
- }
- )
-
- if len(zones_list) != 0:
- person.start_multiple_zones(zones_list)
- _LOGGER.debug("Starting zone(s) %s", entity_id)
- else:
- raise HomeAssistantError("No matching zones found in given entity_ids")
-
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_START_WATERING,
@@ -152,18 +107,6 @@ async def async_setup_entry(
"turn_on",
)
- # If only hose timers on account, none of these services apply
- if not zone_entities:
- return
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_START_MULTIPLE_ZONES,
- start_multiple,
- schema=START_MULTIPLE_ZONES_SCHEMA,
- )
-
if has_flex_sched:
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
diff --git a/homeassistant/components/radio_frequency/__init__.py b/homeassistant/components/radio_frequency/__init__.py
index 087d9e890706..bf4ba5ca5fa6 100644
--- a/homeassistant/components/radio_frequency/__init__.py
+++ b/homeassistant/components/radio_frequency/__init__.py
@@ -6,9 +6,9 @@ import logging
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import Context, HomeAssistant, callback
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -18,11 +18,14 @@ from .entity import (
RadioFrequencyTransmitterEntity,
RadioFrequencyTransmitterEntityDescription,
)
+from .helpers import RadioFrequencyTransmitterConsumerEntity, async_send_command
__all__ = [
"DATA_COMPONENT",
"DOMAIN",
"ModulationType",
+ "RadioFrequencyCommand",
+ "RadioFrequencyTransmitterConsumerEntity",
"RadioFrequencyTransmitterEntity",
"RadioFrequencyTransmitterEntityDescription",
"async_get_transmitters",
@@ -95,60 +98,3 @@ def async_get_transmitters(
if entity.supports_modulation(modulation)
and entity.supports_frequency(frequency)
]
-
-
-async def async_send_command(
- hass: HomeAssistant,
- entity_id_or_uuid: str,
- command: RadioFrequencyCommand,
- context: Context | None = None,
-) -> None:
- """Send an RF command to the specified radio_frequency entity.
-
- Raises:
- vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity
- registry UUID.
- HomeAssistantError: If the radio_frequency component is not loaded or the
- resolved entity is not found.
- """
- component = hass.data.get(DATA_COMPONENT)
- if component is None:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="component_not_loaded",
- )
-
- ent_reg = er.async_get(hass)
- entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
- entity = component.get_entity(entity_id)
- if entity is None:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="entity_not_found",
- translation_placeholders={"entity_id": entity_id},
- )
-
- if not entity.supports_frequency(command.frequency):
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="unsupported_frequency",
- translation_placeholders={
- "entity_id": entity_id,
- "frequency": str(command.frequency),
- },
- )
-
- if not entity.supports_modulation(command.modulation):
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="unsupported_modulation",
- translation_placeholders={
- "entity_id": entity_id,
- "modulation": command.modulation,
- },
- )
-
- if context is not None:
- entity.async_set_context(context)
-
- await entity.async_send_command_internal(command)
diff --git a/homeassistant/components/radio_frequency/helpers.py b/homeassistant/components/radio_frequency/helpers.py
new file mode 100644
index 000000000000..1fbefbfcfd52
--- /dev/null
+++ b/homeassistant/components/radio_frequency/helpers.py
@@ -0,0 +1,171 @@
+"""Helper base entities for integrations that consume RF transmitters."""
+
+import logging
+from typing import override
+
+from rf_protocols import RadioFrequencyCommand
+
+from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ Context,
+ Event,
+ EventStateChangedData,
+ HomeAssistant,
+ callback,
+)
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import (
+ async_track_entity_registry_updated_event,
+ async_track_state_change_event,
+)
+
+from .const import DATA_COMPONENT, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_send_command(
+ hass: HomeAssistant,
+ entity_id_or_uuid: str,
+ command: RadioFrequencyCommand,
+ context: Context | None = None,
+) -> None:
+ """Send an RF command to the specified radio_frequency entity.
+
+ Raises:
+ vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity
+ registry UUID.
+ HomeAssistantError: If the radio_frequency component is not loaded or the
+ resolved entity is not found.
+ """
+ component = hass.data.get(DATA_COMPONENT)
+ if component is None:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="component_not_loaded",
+ )
+
+ ent_reg = er.async_get(hass)
+ entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
+ entity = component.get_entity(entity_id)
+ if entity is None:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="entity_not_found",
+ translation_placeholders={"entity_id": entity_id},
+ )
+
+ if not entity.supports_frequency(command.frequency):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unsupported_frequency",
+ translation_placeholders={
+ "entity_id": entity_id,
+ "frequency": str(command.frequency),
+ },
+ )
+
+ if not entity.supports_modulation(command.modulation):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unsupported_modulation",
+ translation_placeholders={
+ "entity_id": entity_id,
+ "modulation": command.modulation,
+ },
+ )
+
+ if context is not None:
+ entity.async_set_context(context)
+
+ await entity.async_send_command_internal(command)
+
+
+class RadioFrequencyTransmitterConsumerEntity(Entity):
+ """Base entity for integrations that send commands via an RF transmitter.
+
+ Tracks the availability of the underlying RF transmitter entity.
+ """
+
+ _attr_should_poll = False
+ _rf_transmitter_entity_id_or_uuid: str
+ _rf_unsubscribes: list[CALLBACK_TYPE]
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to RF entity state and rename events."""
+ await super().async_added_to_hass()
+
+ self._rf_unsubscribes = []
+ self.async_on_remove(self._async_unsubscribe_rf)
+ self._async_track_rf_entity(
+ er.async_validate_entity_id(
+ er.async_get(self.hass), self._rf_transmitter_entity_id_or_uuid
+ )
+ )
+
+ @callback
+ def _async_unsubscribe_rf(self) -> None:
+ """Tear down the current transmitter subscriptions."""
+ while self._rf_unsubscribes:
+ self._rf_unsubscribes.pop()()
+
+ @callback
+ def _async_track_rf_entity(self, entity_id: str) -> None:
+ """Track state and rename events for the resolved transmitter entity_id."""
+ self._async_unsubscribe_rf()
+ self._rf_unsubscribes.append(
+ async_track_state_change_event(
+ self.hass, [entity_id], self._async_rf_state_changed
+ )
+ )
+ self._rf_unsubscribes.append(
+ async_track_entity_registry_updated_event(
+ self.hass, entity_id, self._async_rf_registry_updated
+ )
+ )
+ rf_state = self.hass.states.get(entity_id)
+ self._attr_available = (
+ rf_state is not None and rf_state.state != STATE_UNAVAILABLE
+ )
+
+ async def _send_command(self, command: RadioFrequencyCommand) -> None:
+ """Send an RF command through the RF transmitter entity."""
+ await async_send_command(
+ self.hass,
+ self._rf_transmitter_entity_id_or_uuid,
+ command,
+ context=self._context,
+ )
+
+ @callback
+ def _async_rf_registry_updated(
+ self, event: Event[er.EventEntityRegistryUpdatedData]
+ ) -> None:
+ """Re-track the transmitter when it is renamed."""
+ data = event.data
+ if data["action"] != "update":
+ return
+ if ATTR_ENTITY_ID not in data["changes"]:
+ return
+ self._async_track_rf_entity(data[ATTR_ENTITY_ID])
+ self.async_write_ha_state()
+
+ @callback
+ def _async_rf_state_changed(self, event: Event[EventStateChangedData]) -> None:
+ """Handle RF entity state changes."""
+ new_state = event.data["new_state"]
+ rf_available = new_state is not None and new_state.state != STATE_UNAVAILABLE
+ if rf_available != self.available:
+ _LOGGER.info(
+ "Radio frequency entity %s used by %s is %s",
+ event.data["entity_id"],
+ self.entity_id,
+ "available" if rf_available else "unavailable",
+ )
+
+ self._attr_available = rf_available
+ self.async_write_ha_state()
diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py
index a006f9ef6363..57f4f5f7e012 100644
--- a/homeassistant/components/rainbird/__init__.py
+++ b/homeassistant/components/rainbird/__init__.py
@@ -132,6 +132,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) ->
entry.runtime_data = data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(async_update_listener))
+
return True
@@ -273,3 +275,10 @@ def _async_fix_device_id(
async def async_unload_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_update_listener(
+ hass: HomeAssistant, entry: RainbirdConfigEntry
+) -> None:
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json
index 3f2aaa86c03c..65a5c6e68f2d 100644
--- a/homeassistant/components/rainbird/manifest.json
+++ b/homeassistant/components/rainbird/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
- "requirements": ["pyrainbird==6.3.1"]
+ "requirements": ["pyrainbird==6.5.0"]
}
diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py
index d44d1d836fee..33a0df382171 100644
--- a/homeassistant/components/recorder/filters.py
+++ b/homeassistant/components/recorder/filters.py
@@ -11,7 +11,7 @@ from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.typing import ConfigType
-from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States, StatesMeta
+from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, StatesMeta
DOMAIN = "history"
HISTORY_FILTERS = "history_filters"
@@ -205,19 +205,6 @@ class Filters:
# - Otherwise: exclude
return i_entities
- def states_entity_filter(self) -> ColumnElement:
- """Generate the States.entity_id filter query.
-
- This is no longer used except by the legacy queries.
- """
-
- def _encoder(data: Any) -> Any:
- """Nothing to encode for states since there is no json."""
- return data
-
- # The type annotation should be improved so the type ignore can be removed
- return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type]
-
def states_metadata_entity_filter(self) -> ColumnElement:
"""Generate the StatesMeta.entity_id filter query."""
diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py
index d3ce05fae688..132a77533e2e 100644
--- a/homeassistant/components/recorder/pool.py
+++ b/homeassistant/components/recorder/pool.py
@@ -159,6 +159,24 @@ class MutexPool(StaticPool):
)
MutexPool.pool_lock.release()
+ @override
+ def dispose(self) -> None:
+ """Dispose of the shared connection under the pool lock.
+
+ StaticPool.dispose() closes the single in-memory connection directly.
+ Without the lock it can close it while another thread has it checked
+ out and is mid-query, freeing the sqlite3 handle underneath a running
+ statement -> segfault. Holding pool_lock makes dispose wait for the
+ in-flight checkout to return first.
+ """
+ # pylint: disable-next=consider-using-with
+ got_lock = MutexPool.pool_lock.acquire(timeout=10)
+ try:
+ super().dispose()
+ finally:
+ if got_lock:
+ MutexPool.pool_lock.release()
+
@override
def _do_get(self) -> ConnectionPoolEntry:
if DEBUG_MUTEX_POOL_TRACE:
diff --git a/homeassistant/components/remote_calendar/__init__.py b/homeassistant/components/remote_calendar/__init__.py
index 910eeae8268c..8137fb49eb4b 100644
--- a/homeassistant/components/remote_calendar/__init__.py
+++ b/homeassistant/components/remote_calendar/__init__.py
@@ -1,4 +1,5 @@
"""The Remote Calendar integration."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py
index 158c73ac4e32..7273bf345e8d 100644
--- a/homeassistant/components/remote_calendar/calendar.py
+++ b/homeassistant/components/remote_calendar/calendar.py
@@ -8,7 +8,7 @@ from ical.event import Event
from ical.timeline import Timeline, materialize_timeline
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -88,15 +88,13 @@ class RemoteCalendarEntity(
return await self.hass.async_add_executor_job(events_in_range)
- @override
- async def async_update(self) -> None:
- """Refresh the timeline.
+ async def _async_update_timeline(self) -> None:
+ """Refresh the timeline and write state.
This is called when the coordinator updates. Creating the timeline may
require walking through the entire calendar and handling recurring
events, so it is done as a separate task without blocking the event loop.
"""
- await super().async_update()
def _get_timeline() -> Timeline | None:
"""Return a materialized timeline with upcoming events."""
@@ -111,6 +109,28 @@ class RemoteCalendarEntity(
self._timeline = await self.hass.async_add_executor_job(_get_timeline)
+ @override
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ await self._async_update_timeline()
+ self.async_write_ha_state()
+
+ @callback
+ @override
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self.coordinator.config_entry.async_create_task(
+ self.hass,
+ self._async_handle_coordinator_update(),
+ name="remote calendar timeline update",
+ )
+
+ async def _async_handle_coordinator_update(self) -> None:
+ """Refresh the timeline and write state."""
+ await self._async_update_timeline()
+ self.async_write_ha_state()
+
def _get_calendar_event(event: Event) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py
index 094cc9194021..7888be0fffca 100644
--- a/homeassistant/components/reolink/button.py
+++ b/homeassistant/components/reolink/button.py
@@ -172,6 +172,12 @@ BUTTON_ENTITIES = (
supported=lambda api, ch: api.supported(ch, "reboot"),
method=lambda api, ch: api.reboot(ch),
),
+ ReolinkButtonEntityDescription(
+ key="pre_siren",
+ translation_key="pre_siren",
+ supported=lambda api, ch: api.supported(ch, "pre_siren"),
+ method=lambda api, ch: api.baichuan.PreAlarm(ch),
+ ),
)
HOST_BUTTON_ENTITIES = (
diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json
index a5716aa76d20..7f5822f8d2a6 100644
--- a/homeassistant/components/reolink/icons.json
+++ b/homeassistant/components/reolink/icons.json
@@ -141,6 +141,9 @@
"guard_set": {
"default": "mdi:crosshairs-gps"
},
+ "pre_siren": {
+ "default": "mdi:alarm-light"
+ },
"ptz_auto": {
"default": "mdi:infinity"
},
@@ -584,6 +587,9 @@
"pre_record": {
"default": "mdi:history"
},
+ "pre_siren_on_event": {
+ "default": "mdi:alarm-light"
+ },
"privacy_mask": {
"default": "mdi:eye",
"state": {
diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json
index f53a51c0c328..89d9d0e2ab9e 100644
--- a/homeassistant/components/reolink/manifest.json
+++ b/homeassistant/components/reolink/manifest.json
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
- "requirements": ["reolink-aio==0.21.3"]
+ "requirements": ["reolink-aio==0.21.4"]
}
diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json
index 9982a3c21bc8..5df5df9c4136 100644
--- a/homeassistant/components/reolink/strings.json
+++ b/homeassistant/components/reolink/strings.json
@@ -203,6 +203,9 @@
"guard_set": {
"name": "Guard set current position"
},
+ "pre_siren": {
+ "name": "Pre-siren"
+ },
"ptz_auto": {
"name": "PTZ continuous rotation"
},
@@ -543,7 +546,8 @@
"autoadaptive": "Auto adaptive",
"off": "[%key:common::state::off%]",
"onatnight": "On at night",
- "schedule": "Schedule"
+ "schedule": "Schedule",
+ "scheduleplus": "Schedule plus"
}
},
"hdr": {
@@ -846,6 +850,9 @@
"pre_record": {
"name": "Pre-recording"
},
+ "pre_siren_on_event": {
+ "name": "Pre-siren on event"
+ },
"privacy_mask": {
"name": "Privacy mask"
},
diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py
index 69be215d546d..2d9dbf3a73b6 100644
--- a/homeassistant/components/reolink/switch.py
+++ b/homeassistant/components/reolink/switch.py
@@ -101,6 +101,16 @@ SWITCH_ENTITIES = (
value=lambda api, ch: api.audio_alarm_enabled(ch),
method=lambda api, ch, value: api.set_audio_alarm(ch, value),
),
+ ReolinkSwitchEntityDescription(
+ key="pre_siren_on_event",
+ cmd_key="GetAudioCfg",
+ cmd_id=264,
+ translation_key="pre_siren_on_event",
+ entity_category=EntityCategory.CONFIG,
+ supported=lambda api, ch: api.supported(ch, "pre_siren"),
+ value=lambda api, ch: api.pre_alarm_enabled(ch),
+ method=lambda api, ch, value: api.set_pre_alarm(ch, value),
+ ),
ReolinkSwitchEntityDescription(
key="auto_tracking",
cmd_key="GetAiCfg",
diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py
index 92846c85f094..7d6627d01ec4 100644
--- a/homeassistant/components/rest_command/__init__.py
+++ b/homeassistant/components/rest_command/__init__.py
@@ -117,12 +117,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
skip_url_encoding = command_config[CONF_SKIP_URL_ENCODING]
auth = None
- digest_middleware = None
+ digest_auth: tuple[str, str] | None = None
if CONF_USERNAME in command_config:
username = command_config[CONF_USERNAME]
password = command_config.get(CONF_PASSWORD, "")
if command_config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
- digest_middleware = aiohttp.DigestAuthMiddleware(username, password)
+ digest_auth = (username, password)
else:
auth = aiohttp.BasicAuth(username, password=password)
@@ -177,8 +177,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Add authentication
if auth is not None:
request_kwargs["auth"] = auth
- elif digest_middleware is not None:
- request_kwargs["middlewares"] = (digest_middleware,)
+ elif digest_auth is not None:
+ request_kwargs["middlewares"] = (
+ aiohttp.DigestAuthMiddleware(*digest_auth),
+ )
async with getattr(websession, method)(
URL(request_url, encoded=skip_url_encoding),
diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py
index 58dc85f1fc12..dd15a21e5bcc 100644
--- a/homeassistant/components/rflink/light.py
+++ b/homeassistant/components/rflink/light.py
@@ -12,6 +12,7 @@ from homeassistant.components.light import (
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
LightEntity,
+ LightEntityStateAttribute,
)
from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE
from homeassistant.core import HomeAssistant
@@ -215,10 +216,13 @@ class DimmableRflinkLight(SwitchableRflinkDevice, LightEntity):
old_state = await self.async_get_last_state()
if (
old_state is not None
- and old_state.attributes.get(ATTR_BRIGHTNESS) is not None
+ and old_state.attributes.get(LightEntityStateAttribute.BRIGHTNESS)
+ is not None
):
# restore also brightness in dimmables devices
- self._brightness = int(old_state.attributes[ATTR_BRIGHTNESS])
+ self._brightness = int(
+ old_state.attributes[LightEntityStateAttribute.BRIGHTNESS]
+ )
@override
async def async_turn_on(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py
index 90393589263c..a67dec515aeb 100644
--- a/homeassistant/components/rfxtrx/__init__.py
+++ b/homeassistant/components/rfxtrx/__init__.py
@@ -1,4 +1,5 @@
"""Support for RFXtrx devices."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import binascii
from collections.abc import Callable, Mapping
@@ -7,7 +8,6 @@ import logging
from typing import Any, NamedTuple, cast
import RFXtrx as rfxtrxmod
-import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -20,7 +20,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
-from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.device_registry import EventDeviceRegistryUpdatedData
@@ -30,9 +30,9 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import ConfigType
from .const import (
- ATTR_EVENT,
CONF_AUTOMATIC_ADD,
CONF_DATA_BITS,
CONF_PROTOCOLS,
@@ -40,9 +40,9 @@ from .const import (
DEVICE_PACKET_TYPE_LIGHTING4,
DOMAIN,
EVENT_RFXTRX_EVENT,
- SERVICE_SEND,
SIGNAL_EVENT,
)
+from .services import async_setup_services
DEFAULT_OFF_DELAY = 2.0
@@ -59,18 +59,6 @@ class DeviceTuple(NamedTuple):
id_string: str
-def _bytearray_string(data: Any) -> bytearray:
- val = cv.string(data)
- try:
- return bytearray.fromhex(val)
- except ValueError as err:
- raise vol.Invalid(
- "Data must be a hex string with multiple of two characters"
- ) from err
-
-
-SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string})
-
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.COVER,
@@ -81,6 +69,15 @@ PLATFORMS = [
Platform.SWITCH,
]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up RFXtrx services."""
+ hass.data.setdefault(DOMAIN, {})
+ async_setup_services(hass)
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the RFXtrx component."""
@@ -97,12 +94,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
return False
- hass.services.async_remove(DOMAIN, SERVICE_SEND)
-
rfx_object = hass.data[DOMAIN][DATA_RFXOBJECT]
await hass.async_add_executor_job(rfx_object.close_connection)
- hass.data.pop(DOMAIN)
+ hass.data[DOMAIN].pop(DATA_RFXOBJECT)
return True
@@ -284,13 +279,6 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx)
)
- def send(call: ServiceCall) -> None:
- event = call.data[ATTR_EVENT]
- rfx_object.transport.send(event)
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA)
-
async def async_setup_platform_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py
index 77316c8d1323..99af025f1c0a 100644
--- a/homeassistant/components/rfxtrx/light.py
+++ b/homeassistant/components/rfxtrx/light.py
@@ -5,7 +5,12 @@ from typing import Any, override
import RFXtrx as rfxtrxmod
-from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ColorMode,
+ LightEntity,
+ LightEntityStateAttribute,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant, callback
@@ -70,7 +75,9 @@ class RfxtrxLight(RfxtrxCommandEntity, LightEntity):
old_state = await self.async_get_last_state()
if old_state is not None:
self._attr_is_on = old_state.state == STATE_ON
- if brightness := old_state.attributes.get(ATTR_BRIGHTNESS):
+ if brightness := old_state.attributes.get(
+ LightEntityStateAttribute.BRIGHTNESS
+ ):
self._attr_brightness = int(brightness)
@override
diff --git a/homeassistant/components/rfxtrx/services.py b/homeassistant/components/rfxtrx/services.py
new file mode 100644
index 000000000000..896b6b8ffd81
--- /dev/null
+++ b/homeassistant/components/rfxtrx/services.py
@@ -0,0 +1,38 @@
+"""Support for RFXtrx services."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
+
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+
+from .const import ATTR_EVENT, DATA_RFXOBJECT, DOMAIN, SERVICE_SEND
+
+
+def _bytearray_string(data: Any) -> bytearray:
+ val = cv.string(data)
+ try:
+ return bytearray.fromhex(val)
+ except ValueError as err:
+ raise vol.Invalid(
+ "Data must be a hex string with multiple of two characters"
+ ) from err
+
+
+SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string})
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register the RFXtrx services."""
+
+ def send(call: ServiceCall) -> None:
+ rfx_object = hass.data.get(DOMAIN, {}).get(DATA_RFXOBJECT)
+ if rfx_object is None:
+ raise HomeAssistantError("RFXtrx is not connected, cannot send event")
+ rfx_object.transport.send(call.data[ATTR_EVENT])
+
+ hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA)
diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py
index 899e54f45ed8..ada6df9a8469 100644
--- a/homeassistant/components/roborock/__init__.py
+++ b/homeassistant/components/roborock/__init__.py
@@ -183,10 +183,7 @@ def _remove_stale_devices(
"Removing device: %s because it no longer exists in your account",
device.name,
)
- device_registry.async_update_device(
- device_id=device.id,
- remove_config_entry_id=entry.entry_id,
- )
+ device_registry.async_remove_device(device.id)
async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
@@ -277,17 +274,22 @@ async def async_setup_device(
_LOGGER.warning("Failed to close device %s: %s", device.duid, err)
return
+ try:
+ await coordinator.async_refresh()
+ except RoborockException as err:
+ _LOGGER.error(
+ "Failed initial attempt to connect to device %s (%s): %s",
+ device.name,
+ device.duid,
+ err,
+ )
+
entry.runtime_data.add(coordinator)
async_dispatcher_send(
hass,
f"roborock_coordinator_added_{entry.entry_id}",
coordinator,
)
- entry.async_create_background_task(
- hass,
- coordinator.async_refresh(),
- name=f"roborock_coordinator_refresh_{coordinator.duid}",
- )
async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py
index 3a4af80c2df2..f6e30aa93306 100644
--- a/homeassistant/components/roborock/coordinator.py
+++ b/homeassistant/components/roborock/coordinator.py
@@ -490,6 +490,26 @@ class RoborockDataUpdateCoordinatorA01[
return self._device
+ZEO_REQUEST_PROTOCOLS = [
+ RoborockZeoProtocol.STATE,
+ RoborockZeoProtocol.COUNTDOWN,
+ RoborockZeoProtocol.WASHING_LEFT,
+ RoborockZeoProtocol.ERROR,
+ RoborockZeoProtocol.TIMES_AFTER_CLEAN,
+ RoborockZeoProtocol.DETERGENT_EMPTY,
+ RoborockZeoProtocol.SOFTENER_EMPTY,
+ RoborockZeoProtocol.DETERGENT_TYPE,
+ RoborockZeoProtocol.SOFTENER_TYPE,
+ RoborockZeoProtocol.MODE,
+ RoborockZeoProtocol.PROGRAM,
+ RoborockZeoProtocol.TEMP,
+ RoborockZeoProtocol.RINSE_TIMES,
+ RoborockZeoProtocol.SPIN_LEVEL,
+ RoborockZeoProtocol.DRYING_MODE,
+ RoborockZeoProtocol.SOUND_SET,
+]
+
+
class RoborockWashingMachineUpdateCoordinator(
RoborockDataUpdateCoordinatorA01[RoborockZeoProtocol]
):
@@ -505,25 +525,11 @@ class RoborockWashingMachineUpdateCoordinator(
"""Initialize."""
super().__init__(hass, config_entry, device)
self.api = api
- self.request_protocols: list[RoborockZeoProtocol] = []
- # This currently only supports the washing machine protocols
+ supported_schema_ids = device.product.supported_schema_ids
self.request_protocols = [
- RoborockZeoProtocol.STATE,
- RoborockZeoProtocol.COUNTDOWN,
- RoborockZeoProtocol.WASHING_LEFT,
- RoborockZeoProtocol.ERROR,
- RoborockZeoProtocol.TIMES_AFTER_CLEAN,
- RoborockZeoProtocol.DETERGENT_EMPTY,
- RoborockZeoProtocol.SOFTENER_EMPTY,
- RoborockZeoProtocol.DETERGENT_TYPE,
- RoborockZeoProtocol.SOFTENER_TYPE,
- RoborockZeoProtocol.MODE,
- RoborockZeoProtocol.PROGRAM,
- RoborockZeoProtocol.TEMP,
- RoborockZeoProtocol.RINSE_TIMES,
- RoborockZeoProtocol.SPIN_LEVEL,
- RoborockZeoProtocol.DRYING_MODE,
- RoborockZeoProtocol.SOUND_SET,
+ protocol
+ for protocol in ZEO_REQUEST_PROTOCOLS
+ if not supported_schema_ids or protocol in supported_schema_ids
]
@override
@@ -540,6 +546,16 @@ class RoborockWashingMachineUpdateCoordinator(
) from ex
+DYAD_REQUEST_PROTOCOLS = [
+ RoborockDyadDataProtocol.STATUS,
+ RoborockDyadDataProtocol.POWER,
+ RoborockDyadDataProtocol.MESH_LEFT,
+ RoborockDyadDataProtocol.BRUSH_LEFT,
+ RoborockDyadDataProtocol.ERROR,
+ RoborockDyadDataProtocol.TOTAL_RUN_TIME,
+]
+
+
class RoborockWetDryVacUpdateCoordinator(
RoborockDataUpdateCoordinatorA01[RoborockDyadDataProtocol]
):
@@ -555,14 +571,11 @@ class RoborockWetDryVacUpdateCoordinator(
"""Initialize."""
super().__init__(hass, config_entry, device)
self.api = api
- # This currenltly only supports the WetDryVac protocols
- self.request_protocols: list[RoborockDyadDataProtocol] = [
- RoborockDyadDataProtocol.STATUS,
- RoborockDyadDataProtocol.POWER,
- RoborockDyadDataProtocol.MESH_LEFT,
- RoborockDyadDataProtocol.BRUSH_LEFT,
- RoborockDyadDataProtocol.ERROR,
- RoborockDyadDataProtocol.TOTAL_RUN_TIME,
+ supported_schema_ids = device.product.supported_schema_ids
+ self.request_protocols = [
+ protocol
+ for protocol in DYAD_REQUEST_PROTOCOLS
+ if not supported_schema_ids or protocol in supported_schema_ids
]
@override
diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json
index 71018ee9e14e..bc018d23789b 100644
--- a/homeassistant/components/roborock/icons.json
+++ b/homeassistant/components/roborock/icons.json
@@ -40,6 +40,11 @@
"default": "mdi:brush"
}
},
+ "image": {
+ "map": {
+ "default": "mdi:floor-plan"
+ }
+ },
"number": {
"volume": {
"default": "mdi:volume-source"
@@ -123,12 +128,18 @@
}
},
"switch": {
+ "button_light": {
+ "default": "mdi:lightbulb"
+ },
"child_lock": {
"default": "mdi:account-lock"
},
"dnd_switch": {
"default": "mdi:bell-cancel"
},
+ "dust_collection": {
+ "default": "mdi:delete"
+ },
"off_peak_switch": {
"default": "mdi:power-plug"
},
diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py
index 6c9e2327e4cd..913eab5c8b1c 100644
--- a/homeassistant/components/roborock/image.py
+++ b/homeassistant/components/roborock/image.py
@@ -14,13 +14,15 @@ 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 AddConfigEntryEntitiesCallback
+from homeassistant.util import dt as dt_util
from .coordinator import (
+ RoborockB01Q10UpdateCoordinator,
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
)
-from .entity import RoborockCoordinatedEntityV1
+from .entity import RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1
_LOGGER = logging.getLogger(__name__)
@@ -40,20 +42,22 @@ async def async_setup_entry(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
- if not isinstance(coordinator, RoborockDataUpdateCoordinator):
- return
- entities = [
- RoborockMap(
- config_entry,
- coordinator,
- coordinator.properties_api.home,
- map_info.map_flag,
- map_info.name,
+ entities: list[ImageEntity] = []
+ if isinstance(coordinator, RoborockDataUpdateCoordinator):
+ entities.extend(
+ RoborockMap(
+ config_entry,
+ coordinator,
+ coordinator.properties_api.home,
+ map_info.map_flag,
+ map_info.name,
+ )
+ for map_info in (
+ coordinator.properties_api.home.home_map_info or {}
+ ).values()
)
- for map_info in (
- coordinator.properties_api.home.home_map_info or {}
- ).values()
- ]
+ elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator):
+ entities.append(RoborockMapQ10(coordinator))
async_add_entities(entities)
for coordinator in coordinators.values():
@@ -134,3 +138,50 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
if (map_content := self._map_content) is None:
raise HomeAssistantError("Map flag not found in coordinator maps")
return map_content.image_content
+
+
+class RoborockMapQ10(RoborockCoordinatedEntityB01Q10, ImageEntity):
+ """A class to let you visualize the current map of a Q10 device.
+
+ The Q10 pushes its current map over MQTT rather than serving it on
+ request, and the multi-map list is not reachable on this channel, so the
+ device exposes a single push-driven map entity.
+ """
+
+ _attr_content_type = "image/png"
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_translation_key = "map"
+
+ def __init__(self, coordinator: RoborockB01Q10UpdateCoordinator) -> None:
+ """Initialize a Roborock Q10 map."""
+ RoborockCoordinatedEntityB01Q10.__init__(
+ self, f"map_{coordinator.duid_slug}", coordinator
+ )
+ ImageEntity.__init__(self, coordinator.hass)
+ self._map_trait = coordinator.api.map
+ self._cached_map: bytes | None = None
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """Register a trait listener for push-based map updates."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self._map_trait.add_update_listener(self._handle_map_update)
+ )
+ # Pick up a map that was pushed before the entity was added.
+ self._handle_map_update()
+
+ @callback
+ def _handle_map_update(self) -> None:
+ """Cache the newly pushed map if its content changed."""
+ image_content = self._map_trait.image_content
+ if image_content is None or image_content == self._cached_map:
+ return
+ self._cached_map = image_content
+ self._attr_image_last_updated = dt_util.utcnow()
+ self.async_write_ha_state()
+
+ @override
+ async def async_image(self) -> bytes | None:
+ """Get the cached image."""
+ return self._cached_map
diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json
index 522f24268277..b5d4e0614747 100644
--- a/homeassistant/components/roborock/manifest.json
+++ b/homeassistant/components/roborock/manifest.json
@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
- "python-roborock==5.22.0",
+ "python-roborock==5.30.0",
"vacuum-map-parser-roborock==0.1.5"
]
}
diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py
index 67dff3ae6b7d..e05884d945b5 100644
--- a/homeassistant/components/roborock/number.py
+++ b/homeassistant/components/roborock/number.py
@@ -5,6 +5,8 @@ from dataclasses import dataclass
import logging
from typing import Any, override
+from roborock.devices.traits.b01 import Q10PropertiesApi
+from roborock.devices.traits.b01.q10 import SoundVolumeTrait
from roborock.devices.traits.v1 import PropertiesApi
from roborock.exceptions import RoborockException
@@ -17,11 +19,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import (
+ RoborockB01Q10UpdateCoordinator,
RoborockConfigEntry,
RoborockCoordinatorType,
RoborockDataUpdateCoordinator,
)
-from .entity import RoborockEntityV1
+from .entity import RoborockCoordinatedEntityB01Q10, RoborockEntityV1
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +62,37 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [
]
+@dataclass(frozen=True, kw_only=True)
+class RoborockNumberDescriptionQ10(NumberEntityDescription):
+ """Class to describe a Roborock Q10 number entity."""
+
+ trait: Callable[[Q10PropertiesApi], SoundVolumeTrait | None]
+ """Function to get the trait backing the entity, if supported."""
+
+ get_value: Callable[[SoundVolumeTrait], float | None]
+ """Function to get the value from the trait."""
+
+ set_value: Callable[[SoundVolumeTrait, float], Coroutine[Any, Any, None]]
+ """Function to set the value on the trait."""
+
+
+Q10_NUMBER_DESCRIPTIONS: list[RoborockNumberDescriptionQ10] = [
+ RoborockNumberDescriptionQ10(
+ key="volume",
+ translation_key="volume",
+ native_min_value=0,
+ native_max_value=100,
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.CONFIG,
+ trait=lambda api: api.volume,
+ get_value=lambda trait: (
+ float(trait.volume) if trait.volume is not None else None
+ ),
+ set_value=lambda trait, value: trait.set_volume(int(value)),
+ )
+]
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
@@ -72,18 +106,29 @@ async def async_setup_entry(
coordinator: RoborockCoordinatorType,
) -> None:
"""Add entities for a specific coordinator."""
- if not isinstance(coordinator, RoborockDataUpdateCoordinator):
- return
- entities = [
- RoborockNumberEntity(
- f"{description.key}_{coordinator.duid_slug}",
- coordinator=coordinator,
- entity_description=description,
- trait=trait,
+ entities: list[NumberEntity] = []
+ if isinstance(coordinator, RoborockDataUpdateCoordinator):
+ entities.extend(
+ RoborockNumberEntity(
+ f"{description.key}_{coordinator.duid_slug}",
+ coordinator=coordinator,
+ entity_description=description,
+ trait=trait,
+ )
+ for description in NUMBER_DESCRIPTIONS
+ if (trait := description.trait(coordinator.properties_api)) is not None
+ )
+ elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator):
+ entities.extend(
+ RoborockNumberEntityQ10(
+ f"{description.key}_{coordinator.duid_slug}",
+ coordinator=coordinator,
+ entity_description=description,
+ trait=q10_trait,
+ )
+ for description in Q10_NUMBER_DESCRIPTIONS
+ if (q10_trait := description.trait(coordinator.api)) is not None
)
- for description in NUMBER_DESCRIPTIONS
- if (trait := description.trait(coordinator.properties_api)) is not None
- ]
async_add_entities(entities)
for coordinator in coordinators.values():
@@ -133,3 +178,45 @@ class RoborockNumberEntity(RoborockEntityV1, NumberEntity):
translation_domain=DOMAIN,
translation_key="update_options_failed",
) from err
+
+
+class RoborockNumberEntityQ10(RoborockCoordinatedEntityB01Q10, NumberEntity):
+ """A class to set a numeric setting on a Roborock Q10 device."""
+
+ entity_description: RoborockNumberDescriptionQ10
+ coordinator: RoborockB01Q10UpdateCoordinator
+
+ def __init__(
+ self,
+ unique_id: str,
+ coordinator: RoborockB01Q10UpdateCoordinator,
+ entity_description: RoborockNumberDescriptionQ10,
+ trait: SoundVolumeTrait,
+ ) -> None:
+ """Create a number entity."""
+ self.entity_description = entity_description
+ self._trait = trait
+ super().__init__(unique_id, coordinator)
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """Register a trait listener for push-based state updates."""
+ await super().async_added_to_hass()
+ self.async_on_remove(self._trait.add_update_listener(self.async_write_ha_state))
+
+ @property
+ @override
+ def native_value(self) -> float | None:
+ """Get native value."""
+ return self.entity_description.get_value(self._trait)
+
+ @override
+ async def async_set_native_value(self, value: float) -> None:
+ """Set number value."""
+ try:
+ await self.entity_description.set_value(self._trait, value)
+ except RoborockException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="update_options_failed",
+ ) from err
diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py
index 3952d8d8bad8..7cb45220f160 100644
--- a/homeassistant/components/roborock/sensor.py
+++ b/homeassistant/components/roborock/sensor.py
@@ -96,7 +96,7 @@ class RoborockSensorDescriptionQ10(SensorEntityDescription):
def _dock_error_value_fn(state: DeviceState) -> str | None:
if (
status := state.status.dock_error_status
- ) is not None and state.status.dock_type != RoborockDockTypeCode.no_dock:
+ ) is not None and state.status.dock_type != RoborockDockTypeCode.o0_dock:
return status.name
return None
@@ -570,7 +570,6 @@ async def async_setup_entry(
entities.extend(
RoborockSensorEntityB01Q7(coordinator, description)
for description in Q7_B01_SENSOR_DESCRIPTIONS
- if description.value_fn(coordinator.data) is not None
)
elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator):
entities.extend(
diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json
index f301f3b50c40..3d496b9738bc 100644
--- a/homeassistant/components/roborock/strings.json
+++ b/homeassistant/components/roborock/strings.json
@@ -125,6 +125,11 @@
"name": "Start"
}
},
+ "image": {
+ "map": {
+ "name": "Map"
+ }
+ },
"number": {
"volume": {
"name": "Volume"
@@ -604,12 +609,18 @@
}
},
"switch": {
+ "button_light": {
+ "name": "Indicator light"
+ },
"child_lock": {
"name": "Child lock"
},
"dnd_switch": {
"name": "Do not disturb"
},
+ "dust_collection": {
+ "name": "Dust collection"
+ },
"off_peak_switch": {
"name": "Off-peak charging"
},
diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py
index c1823a763f0c..42c700bcc8cd 100644
--- a/homeassistant/components/roborock/switch.py
+++ b/homeassistant/components/roborock/switch.py
@@ -6,18 +6,24 @@ import logging
from typing import Any, override
from roborock.devices.traits.b01 import Q10PropertiesApi
-from roborock.devices.traits.b01.q10 import DoNotDisturbTrait
+from roborock.devices.traits.b01.q10 import (
+ ButtonLightTrait,
+ ChildLockTrait,
+ DoNotDisturbTrait,
+ DustCollectionTrait,
+)
from roborock.devices.traits.v1 import PropertiesApi
from roborock.devices.traits.v1.common import RoborockSwitchBase
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.const import EntityCategory
+from homeassistant.const import STATE_OFF, STATE_ON, 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 AddConfigEntryEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
from .coordinator import (
@@ -86,11 +92,14 @@ class RoborockSwitchDescriptionA01(SwitchEntityDescription):
data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol
+type Q10SwitchTrait = ChildLockTrait | DoNotDisturbTrait | DustCollectionTrait
+
+
@dataclass(frozen=True, kw_only=True)
class RoborockSwitchDescriptionQ10(SwitchEntityDescription):
"""Class to describe a Roborock Q10 switch entity."""
- trait: Callable[[Q10PropertiesApi], DoNotDisturbTrait | None]
+ trait: Callable[[Q10PropertiesApi], Q10SwitchTrait | None]
A01_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionA01] = [
@@ -109,7 +118,19 @@ Q10_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionQ10] = [
translation_key="dnd_switch",
entity_category=EntityCategory.CONFIG,
trait=lambda traits: traits.do_not_disturb,
- )
+ ),
+ RoborockSwitchDescriptionQ10(
+ key="child_lock",
+ translation_key="child_lock",
+ entity_category=EntityCategory.CONFIG,
+ trait=lambda traits: traits.child_lock,
+ ),
+ RoborockSwitchDescriptionQ10(
+ key="dust_collection",
+ translation_key="dust_collection",
+ entity_category=EntityCategory.CONFIG,
+ trait=lambda traits: traits.dust_collection,
+ ),
]
@@ -159,6 +180,13 @@ async def async_setup_entry(
for description in Q10_SWITCH_DESCRIPTIONS
if (q10_trait := description.trait(coordinator.api)) is not None
)
+ entities.append(
+ RoborockSwitchQ10ButtonLight(
+ f"button_light_{coordinator.duid_slug}",
+ coordinator,
+ coordinator.api.button_light,
+ )
+ )
async_add_entities(entities)
for coordinator in coordinators.values():
@@ -290,7 +318,7 @@ class RoborockSwitchQ10(RoborockCoordinatedEntityB01Q10, SwitchEntity):
unique_id: str,
coordinator: RoborockB01Q10UpdateCoordinator,
description: RoborockSwitchDescriptionQ10,
- trait: DoNotDisturbTrait,
+ trait: Q10SwitchTrait,
) -> None:
"""Initialize the entity."""
self.entity_description = description
@@ -330,3 +358,63 @@ class RoborockSwitchQ10(RoborockCoordinatedEntityB01Q10, SwitchEntity):
def is_on(self) -> bool | None:
"""Return True if entity is on."""
return self._trait.is_on
+
+
+class RoborockSwitchQ10ButtonLight(
+ RoborockCoordinatedEntityB01Q10, SwitchEntity, RestoreEntity
+):
+ """A class to toggle the indicator / button light of a Roborock Q10 device.
+
+ The device does not report the light state, so the switch is write-only
+ and assumes the state of the last successful command, restored across
+ restarts.
+ """
+
+ _attr_assumed_state = True
+ _attr_entity_category = EntityCategory.CONFIG
+ _attr_translation_key = "button_light"
+
+ def __init__(
+ self,
+ unique_id: str,
+ coordinator: RoborockB01Q10UpdateCoordinator,
+ trait: ButtonLightTrait,
+ ) -> None:
+ """Initialize the entity."""
+ self._trait = trait
+ super().__init__(unique_id, coordinator)
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """Restore the last assumed state."""
+ await super().async_added_to_hass()
+ if (last_state := await self.async_get_last_state()) is not None and (
+ last_state.state in (STATE_ON, STATE_OFF)
+ ):
+ self._attr_is_on = last_state.state == STATE_ON
+
+ @override
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the light."""
+ try:
+ await self._trait.disable()
+ except RoborockException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="update_options_failed",
+ ) from err
+ self._attr_is_on = False
+ self.async_write_ha_state()
+
+ @override
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the light."""
+ try:
+ await self._trait.enable()
+ except RoborockException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="update_options_failed",
+ ) from err
+ self._attr_is_on = True
+ self.async_write_ha_state()
diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py
index 4bf58d7473d8..1e5815b376a3 100644
--- a/homeassistant/components/romy/vacuum.py
+++ b/homeassistant/components/romy/vacuum.py
@@ -38,8 +38,7 @@ FAN_SPEEDS: list[str] = [
# Commonly supported features
SUPPORT_ROMY_ROBOT = (
- VacuumEntityFeature.BATTERY
- | VacuumEntityFeature.RETURN_HOME
+ VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.STATE
| VacuumEntityFeature.START
| VacuumEntityFeature.STOP
@@ -76,7 +75,6 @@ class RomyVacuumEntity(RomyEntity, StateVacuumEntity):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_fan_speed = FAN_SPEEDS[self.romy.fan_speed]
- self._attr_battery_level = self.romy.battery_level
if (status := self.romy.status) is None:
self._attr_activity = None
self.async_write_ha_state()
diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json
index 8d56f3a55633..ccb3844b7d90 100644
--- a/homeassistant/components/ruckus_unleashed/manifest.json
+++ b/homeassistant/components/ruckus_unleashed/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioruckus"],
- "requirements": ["aioruckus==0.42"]
+ "requirements": ["aioruckus==0.46.3"]
}
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
index 64cf366ca6e9..1b6ea774f699 100644
--- a/homeassistant/components/russound_rio/manifest.json
+++ b/homeassistant/components/russound_rio/manifest.json
@@ -1,9 +1,9 @@
{
"domain": "russound_rio",
"name": "Russound RIO",
- "after_dependencies": ["usb"],
"codeowners": ["@noahhusby"],
"config_flow": true,
+ "dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
"integration_type": "hub",
"iot_class": "local_push",
diff --git a/homeassistant/components/ruuvi_gateway/__init__.py b/homeassistant/components/ruuvi_gateway/__init__.py
index 94ebf6fbcf61..c5af0db31540 100644
--- a/homeassistant/components/ruuvi_gateway/__init__.py
+++ b/homeassistant/components/ruuvi_gateway/__init__.py
@@ -1,4 +1,5 @@
"""The Ruuvi Gateway integration."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
diff --git a/homeassistant/components/ruuvitag_ble/__init__.py b/homeassistant/components/ruuvitag_ble/__init__.py
index 01634bfce88c..65d096ca1b20 100644
--- a/homeassistant/components/ruuvitag_ble/__init__.py
+++ b/homeassistant/components/ruuvitag_ble/__init__.py
@@ -1,4 +1,5 @@
"""The ruuvitag_ble integration."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py
index 69251608d09e..57564aeab24b 100644
--- a/homeassistant/components/rympro/__init__.py
+++ b/homeassistant/components/rympro/__init__.py
@@ -2,7 +2,7 @@
import logging
-from pyrympro import CannotConnectError, RymPro, UnauthorizedError
+from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
@@ -22,13 +22,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RymProConfigEntry) -> bo
rympro.set_token(data[CONF_TOKEN])
try:
await rympro.account_info()
- except CannotConnectError as error:
+ except (CannotConnectError, OperationError) as error:
raise ConfigEntryNotReady from error
except UnauthorizedError:
try:
token = await rympro.login(data[CONF_EMAIL], data[CONF_PASSWORD], "ha")
except UnauthorizedError as error:
raise ConfigEntryAuthFailed from error
+ except CannotConnectError as error:
+ raise ConfigEntryNotReady from error
hass.config_entries.async_update_entry(
entry,
data={**data, CONF_TOKEN: token},
diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py
index c1126c552f49..c07ca2fb8873 100644
--- a/homeassistant/components/samsungtv/__init__.py
+++ b/homeassistant/components/samsungtv/__init__.py
@@ -239,16 +239,12 @@ async def async_migrate_entry(
version = config_entry.version
minor_version = config_entry.minor_version
- if version > 2:
- # This means the user has downgraded from a future version
- return False
-
LOGGER.debug("Migrating from version %s.%s", version, minor_version)
# 1 -> 2: Unique ID format changed, so delete and re-import:
if version == 1:
dev_reg = dr.async_get(hass)
- dev_reg.async_clear_config_entry(config_entry.entry_id)
+ dev_reg.async_clear_config_entry(config_entry.entry_id, config_entry.domain)
en_reg = er.async_get(hass)
en_reg.async_clear_config_entry(config_entry.entry_id)
diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py
index 09859bee30c2..064f14abc418 100644
--- a/homeassistant/components/samsungtv/bridge.py
+++ b/homeassistant/components/samsungtv/bridge.py
@@ -649,7 +649,6 @@ class SamsungTVWSBridge(
)
self._remote = None
except ConnectionFailure as err:
- error_details = err.args[0]
if "ms.channel.timeOut" in (error_details := repr(err)):
# The websocket was connected, but the TV is probably asleep
LOGGER.debug(
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
index d1a3a01ded8c..6753818ccd05 100644
--- a/homeassistant/components/samsungtv/manifest.json
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -40,7 +40,7 @@
"samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==3.0.5",
"wakeonlan==3.3.0",
- "async-upnp-client==0.46.2"
+ "async-upnp-client==0.47.0"
],
"ssdp": [
{
diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py
index 8dca2541ab14..7c9578ea9bec 100644
--- a/homeassistant/components/saunum/__init__.py
+++ b/homeassistant/components/saunum/__init__.py
@@ -39,8 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) ->
try:
client = await SaunumClient.create(host)
except (SaunumConnectionError, SaunumTimeoutError) as exc:
- # pylint: disable-next=home-assistant-exception-not-translated
- raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"host": host},
+ ) from exc
entry.async_on_unload(client.async_close)
diff --git a/homeassistant/components/saunum/manifest.json b/homeassistant/components/saunum/manifest.json
index d65394d01ae6..f3458724d6eb 100644
--- a/homeassistant/components/saunum/manifest.json
+++ b/homeassistant/components/saunum/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "platinum",
- "requirements": ["pysaunum==0.6.0"]
+ "requirements": ["pysaunum==0.7.0"]
}
diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json
index 4e3645b66991..9b480f6a1eb0 100644
--- a/homeassistant/components/saunum/strings.json
+++ b/homeassistant/components/saunum/strings.json
@@ -87,6 +87,9 @@
}
},
"exceptions": {
+ "cannot_connect": {
+ "message": "Error connecting to {host}"
+ },
"communication_error": {
"message": "Communication error with sauna control unit"
},
diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py
index b79a22e5ff64..77a4a3aa5050 100644
--- a/homeassistant/components/schedule/__init__.py
+++ b/homeassistant/components/schedule/__init__.py
@@ -7,7 +7,7 @@ from typing import Any, Literal, override
import voluptuous as vol
-from homeassistant.const import (
+from homeassistant.const import ( # noqa: F401
ATTR_EDITABLE,
CONF_ICON,
CONF_ID,
@@ -40,7 +40,7 @@ from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util import dt as dt_util
-from .const import (
+from .const import ( # noqa: F401
ATTR_NEXT_EVENT,
CONF_ALL_DAYS,
CONF_DATA,
@@ -50,6 +50,8 @@ from .const import (
LOGGER,
SERVICE_GET,
WEEKDAY_TO_CONF,
+ ScheduleEntityCapabilityAttribute,
+ ScheduleEntityStateAttribute,
)
STORAGE_VERSION = 1
@@ -256,7 +258,10 @@ class Schedule(CollectionEntity):
"""Schedule entity."""
_entity_component_unrecorded_attributes = frozenset(
- {ATTR_EDITABLE, ATTR_NEXT_EVENT}
+ {
+ ScheduleEntityCapabilityAttribute.EDITABLE,
+ ScheduleEntityStateAttribute.NEXT_EVENT,
+ }
)
_attr_has_entity_name = True
@@ -269,7 +274,9 @@ class Schedule(CollectionEntity):
def __init__(self, config: ConfigType, editable: bool) -> None:
"""Initialize a schedule."""
self._config = ENTITY_SCHEMA(config)
- self._attr_capability_attributes = {ATTR_EDITABLE: editable}
+ self._attr_capability_attributes = {
+ ScheduleEntityCapabilityAttribute.EDITABLE: editable
+ }
self._attr_icon = self._config.get(CONF_ICON)
self._attr_name = self._config[CONF_NAME]
self._attr_unique_id = self._config[CONF_ID]
@@ -380,7 +387,7 @@ class Schedule(CollectionEntity):
break
self._attr_extra_state_attributes = {
- ATTR_NEXT_EVENT: next_event,
+ ScheduleEntityStateAttribute.NEXT_EVENT: next_event,
}
if current_data:
diff --git a/homeassistant/components/schedule/const.py b/homeassistant/components/schedule/const.py
index 410cd00c3a08..5bc026e179df 100644
--- a/homeassistant/components/schedule/const.py
+++ b/homeassistant/components/schedule/const.py
@@ -1,11 +1,25 @@
"""Constants for the schedule integration."""
+from enum import StrEnum
import logging
from typing import Final
DOMAIN: Final = "schedule"
LOGGER = logging.getLogger(__package__)
+
+class ScheduleEntityCapabilityAttribute(StrEnum):
+ """Capability attributes for schedule entities."""
+
+ EDITABLE = "editable"
+
+
+class ScheduleEntityStateAttribute(StrEnum):
+ """State attributes for schedule entities."""
+
+ NEXT_EVENT = "next_event"
+
+
CONF_DATA: Final = "data"
CONF_FRIDAY: Final = "friday"
CONF_FROM: Final = "from"
diff --git a/homeassistant/components/schedule/trigger.py b/homeassistant/components/schedule/trigger.py
index dba6c29b99bd..f637b60433e2 100644
--- a/homeassistant/components/schedule/trigger.py
+++ b/homeassistant/components/schedule/trigger.py
@@ -11,7 +11,7 @@ from homeassistant.helpers.trigger import (
make_entity_target_state_trigger,
)
-from .const import ATTR_NEXT_EVENT, DOMAIN
+from .const import DOMAIN, ScheduleEntityStateAttribute
class ScheduleBackToBackTrigger(EntityTransitionTriggerBase):
@@ -24,8 +24,10 @@ class ScheduleBackToBackTrigger(EntityTransitionTriggerBase):
@override
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the origin matches and the next event changed."""
- from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT)
- to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT)
+ from_next_event = from_state.attributes.get(
+ ScheduleEntityStateAttribute.NEXT_EVENT
+ )
+ to_next_event = to_state.attributes.get(ScheduleEntityStateAttribute.NEXT_EVENT)
return (
from_state.state in self._from_states and from_next_event != to_next_event
diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py
index f77df0155468..06b9bbb4b383 100644
--- a/homeassistant/components/schlage/coordinator.py
+++ b/homeassistant/components/schlage/coordinator.py
@@ -116,9 +116,8 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]):
if removed_locks := previous_locks - current_locks:
LOGGER.debug("Removed locks: %s", ", ".join(removed_locks))
for lock_id in removed_locks:
- device_registry.async_update_device(
- device_id=previous_locks_by_lock_id[lock_id].id,
- remove_config_entry_id=self.config_entry.entry_id,
+ device_registry.async_remove_device(
+ previous_locks_by_lock_id[lock_id].id
)
if new_lock_ids := current_locks - previous_locks:
diff --git a/homeassistant/components/scorpiontrack/__init__.py b/homeassistant/components/scorpiontrack/__init__.py
new file mode 100644
index 000000000000..ebb42ebe8ee1
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/__init__.py
@@ -0,0 +1,32 @@
+"""The ScorpionTrack integration."""
+
+from pyscorpiontrack import ScorpionTrackClient
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import CONF_SHARE_TOKEN, PLATFORMS
+from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ScorpionTrackConfigEntry
+) -> bool:
+ """Set up ScorpionTrack from a config entry."""
+ client = ScorpionTrackClient(
+ session=async_get_clientsession(hass),
+ token=entry.data[CONF_SHARE_TOKEN],
+ )
+ coordinator = ScorpionTrackCoordinator(hass, client, 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: ScorpionTrackConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/scorpiontrack/config_flow.py b/homeassistant/components/scorpiontrack/config_flow.py
new file mode 100644
index 000000000000..f680177219f3
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/config_flow.py
@@ -0,0 +1,92 @@
+"""Config flow for ScorpionTrack."""
+
+import logging
+from typing import Any, override
+
+from pyscorpiontrack import (
+ ScorpionTrackClient,
+ ScorpionTrackConnectionError,
+ ScorpionTrackInvalidTokenError,
+ ScorpionTrackShare,
+ ScorpionTrackShareUnavailableError,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import CONF_SHARE_TOKEN, DEFAULT_NAME, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def _async_validate_input(
+ hass: HomeAssistant, user_input: dict[str, Any]
+) -> ScorpionTrackShare:
+ """Validate the provided share token or share URL."""
+ try:
+ normalized_token = ScorpionTrackClient.extract_token(
+ user_input[CONF_SHARE_TOKEN]
+ )
+ except (ScorpionTrackInvalidTokenError, ValueError) as err:
+ raise ScorpionTrackInvalidTokenError(
+ "Invalid ScorpionTrack share token"
+ ) from err
+
+ client = ScorpionTrackClient(
+ session=async_get_clientsession(hass),
+ token=normalized_token,
+ )
+ return await client.async_get_share()
+
+
+def _share_title(share: ScorpionTrackShare) -> str:
+ """Return the best config entry title for a share."""
+ if share.title:
+ return share.title
+ if share.vehicles:
+ return share.vehicles[0].display_name
+ return DEFAULT_NAME
+
+
+class ScorpionTrackConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for ScorpionTrack."""
+
+ VERSION = 1
+
+ @override
+ 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:
+ share = await _async_validate_input(self.hass, user_input)
+ except ScorpionTrackConnectionError:
+ errors["base"] = "cannot_connect"
+ except ScorpionTrackInvalidTokenError:
+ errors["base"] = "invalid_token"
+ except ScorpionTrackShareUnavailableError:
+ errors["base"] = "share_unavailable"
+ except Exception:
+ _LOGGER.exception(
+ "Unexpected exception while validating ScorpionTrack share"
+ )
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(str(share.id))
+ self._abort_if_unique_id_configured()
+ user_input[CONF_SHARE_TOKEN] = share.token
+ return self.async_create_entry(
+ title=_share_title(share),
+ data=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema({vol.Required(CONF_SHARE_TOKEN): str}),
+ errors=errors,
+ )
diff --git a/homeassistant/components/scorpiontrack/const.py b/homeassistant/components/scorpiontrack/const.py
new file mode 100644
index 000000000000..851d745a35bc
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/const.py
@@ -0,0 +1,15 @@
+"""Constants for the ScorpionTrack integration."""
+
+from datetime import timedelta
+
+from homeassistant.const import Platform
+
+DOMAIN = "scorpiontrack"
+DEFAULT_NAME = "ScorpionTrack"
+MANUFACTURER = "ScorpionTrack"
+
+CONF_SHARE_TOKEN = "share_token"
+
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=2)
+
+PLATFORMS: tuple[Platform, ...] = (Platform.DEVICE_TRACKER,)
diff --git a/homeassistant/components/scorpiontrack/coordinator.py b/homeassistant/components/scorpiontrack/coordinator.py
new file mode 100644
index 000000000000..c8eda3c0a690
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/coordinator.py
@@ -0,0 +1,71 @@
+"""Coordinator for ScorpionTrack."""
+
+import logging
+from typing import override
+
+from pyscorpiontrack import (
+ ScorpionTrackClient,
+ ScorpionTrackConnectionError,
+ ScorpionTrackInvalidTokenError,
+ ScorpionTrackShare,
+ ScorpionTrackShareUnavailableError,
+ ScorpionTrackVehicle,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryError
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+type ScorpionTrackConfigEntry = ConfigEntry[ScorpionTrackCoordinator]
+
+
+class ScorpionTrackCoordinator(DataUpdateCoordinator[ScorpionTrackShare]):
+ """Coordinate shared-location updates."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ client: ScorpionTrackClient,
+ entry: ScorpionTrackConfigEntry,
+ ) -> None:
+ """Initialize the coordinator."""
+ self.client = client
+ self.vehicles_by_id: dict[int, ScorpionTrackVehicle] = {}
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=f"{DOMAIN}_{entry.entry_id}",
+ update_interval=DEFAULT_SCAN_INTERVAL,
+ always_update=False,
+ )
+
+ @override
+ async def _async_update_data(self) -> ScorpionTrackShare:
+ """Fetch updated share data."""
+ try:
+ share = await self.client.async_get_share()
+ except ScorpionTrackConnectionError as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ ) from err
+ except ScorpionTrackInvalidTokenError as err:
+ raise ConfigEntryError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_token",
+ ) from err
+ except ScorpionTrackShareUnavailableError as err:
+ raise ConfigEntryError(
+ translation_domain=DOMAIN,
+ translation_key="share_unavailable",
+ ) from err
+ else:
+ self.vehicles_by_id = {vehicle.id: vehicle for vehicle in share.vehicles}
+ return share
diff --git a/homeassistant/components/scorpiontrack/device_tracker.py b/homeassistant/components/scorpiontrack/device_tracker.py
new file mode 100644
index 000000000000..723f7b585f8b
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/device_tracker.py
@@ -0,0 +1,76 @@
+"""Device tracker platform for ScorpionTrack."""
+
+from typing import override
+
+from pyscorpiontrack import ScorpionTrackVehicle
+
+from homeassistant.components.device_tracker import TrackerEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator
+from .entity import ScorpionTrackEntity
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ScorpionTrackConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up ScorpionTrack tracker entities."""
+ coordinator = entry.runtime_data
+ async_add_entities(
+ ScorpionTrackTrackerEntity(coordinator, vehicle.id)
+ for vehicle in coordinator.data.vehicles
+ )
+
+
+class ScorpionTrackTrackerEntity(ScorpionTrackEntity, TrackerEntity):
+ """Represent the latest shared GPS location for a vehicle."""
+
+ _attr_name = None
+ _attr_translation_key = "vehicle_location"
+
+ def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None:
+ """Initialize the tracker."""
+ super().__init__(coordinator, vehicle_id)
+ self._attr_unique_id = f"{coordinator.data.id}_{vehicle_id}"
+
+ def _available_vehicle(self) -> ScorpionTrackVehicle | None:
+ """Return the vehicle if the tracker is available."""
+ if not super().available:
+ return None
+ return self.get_vehicle()
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return if the tracker is available."""
+ vehicle = self._available_vehicle()
+ if vehicle is None:
+ return False
+
+ return (
+ vehicle.position.latitude is not None
+ and vehicle.position.longitude is not None
+ )
+
+ @property
+ @override
+ def latitude(self) -> float | None:
+ """Return the latitude."""
+ vehicle = self._available_vehicle()
+ if vehicle is None:
+ return None
+ return vehicle.position.latitude
+
+ @property
+ @override
+ def longitude(self) -> float | None:
+ """Return the longitude."""
+ vehicle = self._available_vehicle()
+ if vehicle is None:
+ return None
+ return vehicle.position.longitude
diff --git a/homeassistant/components/scorpiontrack/entity.py b/homeassistant/components/scorpiontrack/entity.py
new file mode 100644
index 000000000000..2fa3c597d6ce
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/entity.py
@@ -0,0 +1,44 @@
+"""Shared entity helpers for ScorpionTrack."""
+
+from typing import override
+
+from pyscorpiontrack import ScorpionTrackShare, ScorpionTrackVehicle
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, MANUFACTURER
+from .coordinator import ScorpionTrackCoordinator
+
+
+class ScorpionTrackEntity(CoordinatorEntity[ScorpionTrackCoordinator]):
+ """Base class for ScorpionTrack vehicle entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._vehicle_id = vehicle_id
+ vehicle = self.get_vehicle()
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{self.share.id}_{vehicle_id}")},
+ manufacturer=vehicle.make or MANUFACTURER,
+ model=vehicle.model,
+ name=vehicle.display_name,
+ )
+
+ @property
+ def share(self) -> ScorpionTrackShare:
+ """Return the active share data."""
+ return self.coordinator.data
+
+ def get_vehicle(self) -> ScorpionTrackVehicle:
+ """Return the matching vehicle."""
+ return self.coordinator.vehicles_by_id[self._vehicle_id]
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return if the entity is available."""
+ return super().available and self._vehicle_id in self.coordinator.vehicles_by_id
diff --git a/homeassistant/components/scorpiontrack/icons.json b/homeassistant/components/scorpiontrack/icons.json
new file mode 100644
index 000000000000..a792c09e859c
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/icons.json
@@ -0,0 +1,9 @@
+{
+ "entity": {
+ "device_tracker": {
+ "vehicle_location": {
+ "default": "mdi:car"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/scorpiontrack/manifest.json b/homeassistant/components/scorpiontrack/manifest.json
new file mode 100644
index 000000000000..0ced3f6c03d5
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "scorpiontrack",
+ "name": "ScorpionTrack",
+ "codeowners": ["@Herbertmt978"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/scorpiontrack",
+ "integration_type": "hub",
+ "iot_class": "cloud_polling",
+ "loggers": ["pyscorpiontrack"],
+ "quality_scale": "silver",
+ "requirements": ["pyscorpiontrack==0.1.1"]
+}
diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml
new file mode 100644
index 000000000000..2cacc2339081
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/quality_scale.yaml
@@ -0,0 +1,93 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: This integration does not provide 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: This integration does not provide actions.
+ docs-triggers:
+ status: exempt
+ comment: This integration does not provide triggers.
+ docs-conditions:
+ status: exempt
+ comment: This integration does not provide conditions.
+ 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: This integration does not provide actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: This integration does not provide an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ ScorpionTrack share links are public tokens rather than account credentials.
+ Replacing an expired or revoked share requires configuring a new share link.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery:
+ status: exempt
+ comment: |
+ Vehicles are exposed through a ScorpionTrack cloud shared-location/API
+ link generated from the ScorpionTrack website. The trackers are standalone
+ units that communicate over their SIM/cellular connection rather than
+ Wi-Fi or the local network, so Home Assistant has no local discovery
+ mechanism.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ Vehicles are exposed through a ScorpionTrack cloud shared-location/API
+ link generated from the ScorpionTrack website. The trackers are standalone
+ units that communicate over their SIM/cellular connection rather than
+ Wi-Fi or the local network, so Home Assistant has no local discovery
+ mechanism.
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: The created entities are useful for shared vehicle tracking and should be enabled by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/scorpiontrack/strings.json b/homeassistant/components/scorpiontrack/strings.json
new file mode 100644
index 000000000000..63595a78ae09
--- /dev/null
+++ b/homeassistant/components/scorpiontrack/strings.json
@@ -0,0 +1,36 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "This shared location is already configured."
+ },
+ "error": {
+ "cannot_connect": "The ScorpionTrack share could not be reached right now.",
+ "invalid_token": "Your shared-location link is invalid or incomplete.",
+ "share_unavailable": "Your shared-location link is invalid, expired, has been revoked, or no longer returns data.",
+ "unknown": "An unexpected error occurred."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "share_token": "Share URL or token"
+ },
+ "data_description": {
+ "share_token": "Paste the full ScorpionTrack shared-location URL or just the token itself."
+ },
+ "description": "Paste a ScorpionTrack shared-location URL or the token from that URL.",
+ "title": "Connect a ScorpionTrack share"
+ }
+ }
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "The ScorpionTrack share could not be reached right now."
+ },
+ "invalid_token": {
+ "message": "ScorpionTrack rejected the configured share token."
+ },
+ "share_unavailable": {
+ "message": "The shared location is unavailable."
+ }
+ }
+}
diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py
index 95177b7d2b46..1fb19d2663ca 100644
--- a/homeassistant/components/scrape/__init__.py
+++ b/homeassistant/components/scrape/__init__.py
@@ -236,20 +236,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) ->
)
device_reg.async_update_device(
device.id,
- add_config_entry_id=entry.entry_id,
- add_config_subentry_id=subentry_id,
+ new_config_entry_id=entry.entry_id,
+ new_config_subentry_id=subentry_id,
new_identifiers=new_identifiers,
)
- # Removing None from the list of subentries if existing
- # as the device should only belong to the subentry
- # and not the main config entry
- device_reg.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- remove_config_subentry_id=None,
- )
-
# Update the resource config
new_config_entry_data = dict(entry.options)
new_config_entry_data[CONF_AUTH] = {}
diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py
index bb40f3cfe2db..19c390c83ab5 100644
--- a/homeassistant/components/screenlogic/climate.py
+++ b/homeassistant/components/screenlogic/climate.py
@@ -11,10 +11,10 @@ from screenlogicpy.device_const.heat import HEAT_MODE
from screenlogicpy.device_const.system import EQUIPMENT_FLAG
from homeassistant.components.climate import (
- ATTR_PRESET_MODE,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
+ ClimateEntityStateAttribute,
HVACAction,
HVACMode,
)
@@ -212,9 +212,12 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
prev_state = await self.async_get_last_state()
if (
prev_state is not None
- and prev_state.attributes.get(ATTR_PRESET_MODE) is not None
+ and prev_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE)
+ is not None
):
- mode = HEAT_MODE.parse(prev_state.attributes.get(ATTR_PRESET_MODE))
+ mode = HEAT_MODE.parse(
+ prev_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE)
+ )
_LOGGER.debug(
"Startup setting last_preset to %s from prev_state",
mode.name,
diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py
index b9fbd959a965..1eef57773780 100644
--- a/homeassistant/components/screenlogic/config_flow.py
+++ b/homeassistant/components/screenlogic/config_flow.py
@@ -8,19 +8,12 @@ from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWA
from screenlogicpy.requests import login
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
-from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL
-from homeassistant.core import callback
-from homeassistant.helpers import config_validation as cv
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -73,15 +66,6 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN):
self.discovered_gateways: dict[str, dict[str, Any]] = {}
self.discovered_ip: str | None = None
- @staticmethod
- @callback
- @override
- def async_get_options_flow(
- config_entry: ConfigEntry,
- ) -> ScreenLogicOptionsFlowHandler:
- """Get the options flow for ScreenLogic."""
- return ScreenLogicOptionsFlowHandler()
-
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -188,32 +172,3 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
description_placeholders={},
)
-
-
-class ScreenLogicOptionsFlowHandler(OptionsFlow):
- """Handles the options for the ScreenLogic integration."""
-
- async def async_step_init(self, user_input=None) -> ConfigFlowResult:
- """Manage the options."""
- if user_input is not None:
- return self.async_create_entry(
- title=self.config_entry.title, data=user_input
- )
-
- current_interval = self.config_entry.options.get(
- CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
- )
- return self.async_show_form(
- step_id="init",
- data_schema=vol.Schema(
- {
- # Polling interval is user-configurable, which is no longer allowed
- # pylint: disable-next=home-assistant-config-flow-polling-field
- vol.Required(
- CONF_SCAN_INTERVAL,
- default=current_interval,
- ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL))
- }
- ),
- description_placeholders={"gateway_name": self.config_entry.title},
- )
diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py
index 2104f6a9ba22..b77fa0f77989 100644
--- a/homeassistant/components/screenlogic/coordinator.py
+++ b/homeassistant/components/screenlogic/coordinator.py
@@ -14,7 +14,7 @@ from screenlogicpy.const.common import (
from screenlogicpy.device_const.system import EQUIPMENT_FLAG
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL
+from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -64,15 +64,12 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Initialize the Screenlogic Data Update Coordinator."""
self.gateway = gateway
- interval = timedelta(
- seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
- )
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
- update_interval=interval,
+ update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
# Debounced option since the device takes
# a moment to reflect the knock-on changes
request_refresh_debouncer=Debouncer(
diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json
index 026597eaff9f..2ed0edd9399f 100644
--- a/homeassistant/components/screenlogic/strings.json
+++ b/homeassistant/components/screenlogic/strings.json
@@ -190,17 +190,6 @@
}
}
},
- "options": {
- "step": {
- "init": {
- "data": {
- "scan_interval": "Seconds between scans"
- },
- "description": "Specify settings for {gateway_name}",
- "title": "[%key:component::screenlogic::config::step::gateway_entry::title%]"
- }
- }
- },
"services": {
"set_color_mode": {
"description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.",
diff --git a/homeassistant/components/script/llm.py b/homeassistant/components/script/llm.py
new file mode 100644
index 000000000000..a669b6709110
--- /dev/null
+++ b/homeassistant/components/script/llm.py
@@ -0,0 +1,61 @@
+"""LLM tools for the script integration."""
+
+from operator import attrgetter
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback, split_entity_id
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.llm import LLM_API_ASSIST, ActionTool, LLMContext, Tool
+
+from .const import DOMAIN
+
+
+class ScriptTool(ActionTool):
+ """LLM Tool representing a Script."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ script_entity_id: str,
+ ) -> None:
+ """Init the class."""
+ script_name = split_entity_id(script_entity_id)[1]
+
+ action = script_name
+ entity_registry = er.async_get(hass)
+ entity_entry = entity_registry.async_get(script_entity_id)
+ if entity_entry and entity_entry.unique_id:
+ action = entity_entry.unique_id
+
+ super().__init__(hass, DOMAIN, action)
+
+ self.name = script_name
+ if self.name[0].isdigit():
+ self.name = "_" + self.name
+
+ if entity_entry and (
+ aliases := er.async_get_entity_aliases(hass, entity_entry)
+ ):
+ alias_text = "Aliases: " + str(sorted(aliases))
+ self.description = (
+ f"{self.description}. {alias_text}" if self.description else alias_text
+ )
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return a script LLM tool for each exposed script."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ tools: list[Tool] = [
+ ScriptTool(hass, state.entity_id)
+ for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name"))
+ if async_should_expose(hass, llm_context.assistant, state.entity_id)
+ ]
+ if not tools:
+ return None
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py
index 214da9aacb16..5b6111e04b48 100644
--- a/homeassistant/components/search/__init__.py
+++ b/homeassistant/components/search/__init__.py
@@ -220,6 +220,12 @@ class Searcher:
automation.blueprint_in_automation(self.hass, automation_entity_id),
)
+ # Labels referenced in this automation
+ self._add(
+ ItemType.LABEL,
+ automation.labels_in_automation(self.hass, automation_entity_id),
+ )
+
# Floors referenced in this automation
self._add(
ItemType.FLOOR,
@@ -290,6 +296,19 @@ class Searcher:
self._add(ItemType.ENTITY, entity_entry.entity_id)
self._async_search_entity(entity_entry.entity_id, entry_point=False)
+ @callback
+ def _async_search_integration(self, domain: str) -> None:
+ """Find results for an integration."""
+ for entry in self.hass.config_entries.async_entries(domain):
+ self._add(ItemType.CONFIG_ENTRY, entry.entry_id)
+ self._async_search_config_entry(entry.entry_id)
+
+ for entity_id, source in self._entity_sources.items():
+ if source["domain"] != domain:
+ continue
+ self._add(ItemType.ENTITY, entity_id)
+ self._async_search_entity(entity_id, entry_point=False)
+
@callback
def _async_search_device(self, device_id: str, *, entry_point: bool = True) -> None:
"""Find results for a device."""
@@ -327,6 +346,12 @@ class Searcher:
# Add labels of this entity
self._add(ItemType.LABEL, entity_entry.labels)
+ if not entry_point:
+ # If this entity also exists as a resource, we add it.
+ domain = split_entity_id(entity_id)[0]
+ if domain in self.EXIST_AS_ENTITY:
+ self._add(ItemType(domain), entity_id)
+
# Automations referencing this entity
self._add(
ItemType.AUTOMATION,
@@ -394,14 +419,17 @@ class Searcher:
# Areas with this label
for area_entry in ar.async_entries_for_label(self._area_registry, label_id):
self._add(ItemType.AREA, area_entry.id)
+ self._async_resolve_up_area(area_entry.id)
# Devices with this label
for device in dr.async_entries_for_label(self._device_registry, label_id):
self._add(ItemType.DEVICE, device.id)
+ self._async_resolve_up_device(device.id)
# Entities with this label
for entity_entry in er.async_entries_for_label(self._entity_registry, label_id):
self._add(ItemType.ENTITY, entity_entry.entity_id)
+ self._async_resolve_up_entity(entity_entry.entity_id)
# If this entity also exists as a resource, we add it.
domain = split_entity_id(entity_entry.entity_id)[0]
@@ -420,7 +448,7 @@ class Searcher:
@callback
def _async_search_person(self, person_entity_id: str) -> None:
"""Find results for a person."""
- # Up resolve the scene entity itself
+ # Up resolve the person entity itself
if entity_entry := self._async_resolve_up_entity(person_entity_id):
# Add labels of this person entity
self._add(ItemType.LABEL, entity_entry.labels)
@@ -437,9 +465,9 @@ class Searcher:
)
# Add all member entities of this person
- self._add(
- ItemType.ENTITY, person.entities_in_person(self.hass, person_entity_id)
- )
+ for entity_id in person.entities_in_person(self.hass, person_entity_id):
+ self._add(ItemType.ENTITY, entity_id)
+ self._async_resolve_up_entity(entity_id)
@callback
def _async_search_scene(self, scene_entity_id: str) -> None:
@@ -483,6 +511,9 @@ class Searcher:
script.blueprint_in_script(self.hass, script_entity_id),
)
+ # Labels referenced in this script
+ self._add(ItemType.LABEL, script.labels_in_script(self.hass, script_entity_id))
+
# Floors referenced in this script
self._add(ItemType.FLOOR, script.floors_in_script(self.hass, script_entity_id))
diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json
index 07187066dcde..dea32f63c792 100644
--- a/homeassistant/components/sense/manifest.json
+++ b/homeassistant/components/sense/manifest.json
@@ -21,5 +21,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
- "requirements": ["sense-energy==0.14.1"]
+ "requirements": ["sense-energy==0.14.3"]
}
diff --git a/homeassistant/components/sensorpro/__init__.py b/homeassistant/components/sensorpro/__init__.py
index 167c5d167462..4e8c131d698d 100644
--- a/homeassistant/components/sensorpro/__init__.py
+++ b/homeassistant/components/sensorpro/__init__.py
@@ -1,4 +1,5 @@
"""The SensorPro integration."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json
index 75906382a1b4..dc46f2c8ff31 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==12.2.0"]
+ "requirements": ["Pillow==12.3.0"]
}
diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py
index dd82a9e93c49..ac6de4777989 100644
--- a/homeassistant/components/sharkiq/vacuum.py
+++ b/homeassistant/components/sharkiq/vacuum.py
@@ -65,8 +65,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = (
- VacuumEntityFeature.BATTERY
- | VacuumEntityFeature.FAN_SPEED
+ VacuumEntityFeature.FAN_SPEED
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.START
@@ -157,12 +156,6 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
# If the last update was successful...
return self.coordinator.last_update_success and self.is_online
- @property
- @override
- def battery_level(self) -> int | None:
- """Get the current battery level."""
- return self.sharkiq.get_property_value(Properties.BATTERY_CAPACITY)
-
@override
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Have the device return to base."""
diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py
index 1f71d5751003..94c3e4ce26ef 100644
--- a/homeassistant/components/shelly/button.py
+++ b/homeassistant/components/shelly/button.py
@@ -2,10 +2,9 @@
from collections.abc import Callable
from dataclasses import dataclass
-from functools import partial
from typing import TYPE_CHECKING, Any, Final, override
-from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS
+from aioshelly.const import MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
from homeassistant.components.button import (
@@ -15,16 +14,14 @@ from homeassistant.components.button import (
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
CONF_SLEEP_PERIOD,
DOMAIN,
- LOGGER,
MODEL_FRANKEVER_WATER_VALVE,
ROLE_GENERIC,
SHELLY_GAS_MODELS,
@@ -47,9 +44,6 @@ from .utils import (
get_blu_trv_device_info,
get_device_entry_gen,
get_rpc_key_id,
- get_rpc_key_ids,
- get_rpc_key_instances,
- get_rpc_role_by_key,
get_virtual_component_ids,
)
@@ -119,76 +113,6 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
]
-@callback
-def async_migrate_unique_ids(
- coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator,
- entity_entry: er.RegistryEntry,
-) -> dict[str, Any] | None:
- """Migrate button unique IDs."""
- if not entity_entry.entity_id.startswith("button"):
- return None
-
- for key in ("reboot", "self_test", "mute", "unmute"):
- old_unique_id = f"{coordinator.mac}_{key}"
- if entity_entry.unique_id == old_unique_id:
- new_unique_id = f"{coordinator.mac}-{key}"
- LOGGER.debug(
- "Migrating unique_id for %s entity from [%s] to [%s]",
- entity_entry.entity_id,
- old_unique_id,
- new_unique_id,
- )
- return {
- "new_unique_id": entity_entry.unique_id.replace(
- old_unique_id, new_unique_id
- )
- }
-
- if not isinstance(coordinator, ShellyRpcCoordinator):
- return None
-
- if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
- for _id in blutrv_key_ids:
- key = f"{BLU_TRV_IDENTIFIER}:{_id}"
- ble_addr: str = coordinator.device.config[key]["addr"]
- old_unique_id = f"{ble_addr}_calibrate"
- if entity_entry.unique_id == old_unique_id:
- new_unique_id = f"{format_ble_addr(ble_addr)}-{key}-calibrate"
- LOGGER.debug(
- "Migrating unique_id for %s entity from [%s] to [%s]",
- entity_entry.entity_id,
- old_unique_id,
- new_unique_id,
- )
- return {
- "new_unique_id": entity_entry.unique_id.replace(
- old_unique_id, new_unique_id
- )
- }
-
- if virtual_button_keys := get_rpc_key_instances(
- coordinator.device.config, "button"
- ):
- for key in virtual_button_keys:
- old_unique_id = f"{coordinator.mac}-{key}"
- if entity_entry.unique_id == old_unique_id:
- role = get_rpc_role_by_key(coordinator.device.config, key)
- new_unique_id = f"{coordinator.mac}-{key}-button_{role}"
- LOGGER.debug(
- "Migrating unique_id for %s entity from [%s] to [%s]",
- entity_entry.entity_id,
- old_unique_id,
- new_unique_id,
- )
- return {
- "new_unique_id": entity_entry.unique_id.replace(
- old_unique_id, new_unique_id
- )
- }
-
- return None
-
-
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
@@ -206,11 +130,6 @@ async def async_setup_entry(
if TYPE_CHECKING:
assert coordinator is not None
- if coordinator.device.initialized:
- await er.async_migrate_entries(
- hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
- )
-
# Remove the 'restart' button for sleeping devices as it was mistakenly
# added in https://github.com/home-assistant/core/pull/154673
entry_sleep_period = config_entry.data[CONF_SLEEP_PERIOD]
diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json
index 02f5979e2158..93d326bc33fe 100644
--- a/homeassistant/components/shelly/manifest.json
+++ b/homeassistant/components/shelly/manifest.json
@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
- "requirements": ["aioshelly==13.26.2"],
+ "requirements": ["aioshelly==13.27.0"],
"zeroconf": [
{
"name": "shelly*",
diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py
index c75565c1dd76..304a90d19ae7 100644
--- a/homeassistant/components/shelly/update.py
+++ b/homeassistant/components/shelly/update.py
@@ -10,12 +10,11 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
from homeassistant.components.update import (
- ATTR_INSTALLED_VERSION,
- ATTR_LATEST_VERSION,
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
+ UpdateEntityStateAttribute,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
@@ -391,7 +390,9 @@ class RpcSleepingUpdateEntity(
if self.last_state is None:
return None
- return self.last_state.attributes.get(ATTR_INSTALLED_VERSION)
+ return self.last_state.attributes.get(
+ UpdateEntityStateAttribute.INSTALLED_VERSION
+ )
@property
@override
@@ -407,7 +408,7 @@ class RpcSleepingUpdateEntity(
if self.last_state is None:
return None
- return self.last_state.attributes.get(ATTR_LATEST_VERSION)
+ return self.last_state.attributes.get(UpdateEntityStateAttribute.LATEST_VERSION)
@property
@override
diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py
index 9eccf34badc6..a6aebddbc023 100644
--- a/homeassistant/components/shelly/utils.py
+++ b/homeassistant/components/shelly/utils.py
@@ -916,7 +916,7 @@ def remove_stale_blu_trv_devices(
continue
LOGGER.debug("Removing stale BLU TRV device %s", device.name)
- dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id)
+ dev_reg.async_remove_device(device.id)
@callback
@@ -938,9 +938,7 @@ def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None:
if any(identifier[0] == DOMAIN for identifier in device.identifiers):
LOGGER.debug("Removing empty sub-device %s", device.name)
- dev_reg.async_update_device(
- device.id, remove_config_entry_id=entry.entry_id
- )
+ dev_reg.async_remove_device(device.id)
def format_ble_addr(ble_addr: str) -> str:
diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py
index c1b7eaf54fde..a2527f5d8307 100644
--- a/homeassistant/components/shopping_list/__init__.py
+++ b/homeassistant/components/shopping_list/__init__.py
@@ -10,14 +10,9 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import http, websocket_api
from homeassistant.components.http.data_validator import RequestDataValidator
-from homeassistant.const import ATTR_NAME, Platform
-from homeassistant.core import (
- DOMAIN as HOMEASSISTANT_DOMAIN,
- HomeAssistant,
- ServiceCall,
- callback,
-)
-from homeassistant.helpers import config_validation as cv, issue_registry as ir
+from homeassistant.const import Platform
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from .common import (
@@ -26,19 +21,8 @@ from .common import (
ShoppingListConfigEntry,
_get_shopping_data,
)
-from .const import (
- ATTR_REVERSE,
- DEFAULT_REVERSE,
- DOMAIN,
- SERVICE_ADD_ITEM,
- SERVICE_CLEAR_COMPLETED_ITEMS,
- SERVICE_COMPLETE_ALL,
- SERVICE_COMPLETE_ITEM,
- SERVICE_INCOMPLETE_ALL,
- SERVICE_INCOMPLETE_ITEM,
- SERVICE_REMOVE_ITEM,
- SERVICE_SORT,
-)
+from .const import DOMAIN
+from .services import async_register_services
PLATFORMS = [Platform.TODO]
@@ -46,15 +30,10 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA)
-SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string})
-SERVICE_LIST_SCHEMA = vol.Schema({})
-SERVICE_SORT_SCHEMA = vol.Schema(
- {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool}
-)
-
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the shopping list."""
+ async_register_services(hass)
if DOMAIN not in config:
return True
@@ -88,113 +67,9 @@ async def async_setup_entry(
hass: HomeAssistant, config_entry: ShoppingListConfigEntry
) -> bool:
"""Set up shopping list from config flow."""
-
- async def add_item_service(call: ServiceCall) -> None:
- """Add an item with `name`."""
- await config_entry.runtime_data.async_add(call.data[ATTR_NAME])
-
- async def remove_item_service(call: ServiceCall) -> None:
- """Remove the first item with matching `name`."""
- data = config_entry.runtime_data
- name = call.data[ATTR_NAME]
-
- try:
- item = [item for item in data.items if item["name"] == name][0]
- # pylint: disable-next=home-assistant-action-swallowed-exception
- except IndexError:
- _LOGGER.error("Removing of item failed: %s cannot be found", name)
- else:
- await data.async_remove(str(item["id"]))
-
- async def complete_item_service(call: ServiceCall) -> None:
- """Mark the first item with matching `name` as completed."""
- name = call.data[ATTR_NAME]
- try:
- await config_entry.runtime_data.async_complete(name)
- # pylint: disable-next=home-assistant-action-swallowed-exception
- except NoMatchingShoppingListItem:
- _LOGGER.error("Completing of item failed: %s cannot be found", name)
-
- async def incomplete_item_service(call: ServiceCall) -> None:
- """Mark the first item with matching `name` as incomplete."""
- data = config_entry.runtime_data
- name = call.data[ATTR_NAME]
-
- try:
- item = [item for item in data.items if item["name"] == name][0]
- # pylint: disable-next=home-assistant-action-swallowed-exception
- except IndexError:
- _LOGGER.error("Restoring of item failed: %s cannot be found", name)
- else:
- await data.async_update(str(item["id"]), {"name": name, "complete": False})
-
- async def complete_all_service(call: ServiceCall) -> None:
- """Mark all items in the list as complete."""
- await data.async_update_list({"complete": True})
-
- async def incomplete_all_service(call: ServiceCall) -> None:
- """Mark all items in the list as incomplete."""
- await data.async_update_list({"complete": False})
-
- async def clear_completed_items_service(call: ServiceCall) -> None:
- """Clear all completed items from the list."""
- await data.async_clear_completed()
-
- async def sort_list_service(call: ServiceCall) -> None:
- """Sort all items by name."""
- await data.async_sort(call.data[ATTR_REVERSE])
-
data = config_entry.runtime_data = ShoppingData(hass)
await data.async_load()
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_INCOMPLETE_ITEM,
- incomplete_item_service,
- schema=SERVICE_ITEM_SCHEMA,
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_COMPLETE_ALL,
- complete_all_service,
- schema=SERVICE_LIST_SCHEMA,
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_INCOMPLETE_ALL,
- incomplete_all_service,
- schema=SERVICE_LIST_SCHEMA,
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_CLEAR_COMPLETED_ITEMS,
- clear_completed_items_service,
- schema=SERVICE_LIST_SCHEMA,
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_SORT,
- sort_list_service,
- schema=SERVICE_SORT_SCHEMA,
- )
-
hass.http.register_view(ShoppingListView)
hass.http.register_view(CreateShoppingListItemView)
hass.http.register_view(UpdateShoppingListItemView)
diff --git a/homeassistant/components/shopping_list/common.py b/homeassistant/components/shopping_list/common.py
index 4a8523c39e32..4305ba84bded 100644
--- a/homeassistant/components/shopping_list/common.py
+++ b/homeassistant/components/shopping_list/common.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_NAME
from homeassistant.core import Context, HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import service
from homeassistant.helpers.json import save_json
from homeassistant.util.json import JsonValueType, load_json_array
@@ -271,9 +271,5 @@ class ShoppingData:
def _get_shopping_data(hass: HomeAssistant) -> ShoppingData:
- entries: list[ShoppingListConfigEntry] = hass.config_entries.async_loaded_entries(
- DOMAIN
- )
- if not entries:
- raise HomeAssistantError("No shopping list config entry found")
- return entries[0].runtime_data
+ entry: ShoppingListConfigEntry = service.async_get_config_entry(hass, DOMAIN, None)
+ return entry.runtime_data
diff --git a/homeassistant/components/shopping_list/services.py b/homeassistant/components/shopping_list/services.py
new file mode 100644
index 000000000000..693c0d15b0be
--- /dev/null
+++ b/homeassistant/components/shopping_list/services.py
@@ -0,0 +1,132 @@
+"""Support for shopping list services."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_NAME
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.helpers import config_validation as cv
+
+from .common import NoMatchingShoppingListItem, _get_shopping_data
+from .const import (
+ ATTR_REVERSE,
+ DEFAULT_REVERSE,
+ DOMAIN,
+ SERVICE_ADD_ITEM,
+ SERVICE_CLEAR_COMPLETED_ITEMS,
+ SERVICE_COMPLETE_ALL,
+ SERVICE_COMPLETE_ITEM,
+ SERVICE_INCOMPLETE_ALL,
+ SERVICE_INCOMPLETE_ITEM,
+ SERVICE_REMOVE_ITEM,
+ SERVICE_SORT,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string})
+SERVICE_LIST_SCHEMA = vol.Schema({})
+SERVICE_SORT_SCHEMA = vol.Schema(
+ {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool}
+)
+
+
+@callback
+def async_register_services(hass: HomeAssistant) -> None:
+ """Register shopping list services."""
+
+ async def add_item_service(call: ServiceCall) -> None:
+ """Add an item with `name`."""
+ await _get_shopping_data(hass).async_add(call.data[ATTR_NAME])
+
+ async def remove_item_service(call: ServiceCall) -> None:
+ """Remove the first item with matching `name`."""
+ data = _get_shopping_data(hass)
+ name = call.data[ATTR_NAME]
+
+ try:
+ item = [item for item in data.items if item["name"] == name][0]
+ # pylint: disable-next=home-assistant-action-swallowed-exception
+ except IndexError:
+ _LOGGER.error("Removing of item failed: %s cannot be found", name)
+ else:
+ await data.async_remove(str(item["id"]))
+
+ async def complete_item_service(call: ServiceCall) -> None:
+ """Mark the first item with matching `name` as completed."""
+ name = call.data[ATTR_NAME]
+ try:
+ await _get_shopping_data(hass).async_complete(name)
+ # pylint: disable-next=home-assistant-action-swallowed-exception
+ except NoMatchingShoppingListItem:
+ _LOGGER.error("Completing of item failed: %s cannot be found", name)
+
+ async def incomplete_item_service(call: ServiceCall) -> None:
+ """Mark the first item with matching `name` as incomplete."""
+ data = _get_shopping_data(hass)
+ name = call.data[ATTR_NAME]
+
+ try:
+ item = [item for item in data.items if item["name"] == name][0]
+ # pylint: disable-next=home-assistant-action-swallowed-exception
+ except IndexError:
+ _LOGGER.error("Restoring of item failed: %s cannot be found", name)
+ else:
+ await data.async_update(str(item["id"]), {"name": name, "complete": False})
+
+ async def complete_all_service(call: ServiceCall) -> None:
+ """Mark all items in the list as complete."""
+ await _get_shopping_data(hass).async_update_list({"complete": True})
+
+ async def incomplete_all_service(call: ServiceCall) -> None:
+ """Mark all items in the list as incomplete."""
+ await _get_shopping_data(hass).async_update_list({"complete": False})
+
+ async def clear_completed_items_service(call: ServiceCall) -> None:
+ """Clear all completed items from the list."""
+ await _get_shopping_data(hass).async_clear_completed()
+
+ async def sort_list_service(call: ServiceCall) -> None:
+ """Sort all items by name."""
+ await _get_shopping_data(hass).async_sort(call.data[ATTR_REVERSE])
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_INCOMPLETE_ITEM,
+ incomplete_item_service,
+ schema=SERVICE_ITEM_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_COMPLETE_ALL,
+ complete_all_service,
+ schema=SERVICE_LIST_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_INCOMPLETE_ALL,
+ incomplete_all_service,
+ schema=SERVICE_LIST_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_CLEAR_COMPLETED_ITEMS,
+ clear_completed_items_service,
+ schema=SERVICE_LIST_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SORT,
+ sort_list_service,
+ schema=SERVICE_SORT_SCHEMA,
+ )
diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json
index 5c01cf26697a..bb537ccea6ea 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==12.2.0", "simplehound==0.3"]
+ "requirements": ["Pillow==12.3.0", "simplehound==0.3"]
}
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index d65d930c7739..df648d618854 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -1,7 +1,6 @@
"""Support for SimpliSafe alarm systems."""
import asyncio
-from collections.abc import Callable, Coroutine
from typing import Any
from simplipy import API
@@ -13,18 +12,6 @@ from simplipy.errors import (
WebsocketError,
)
from simplipy.system import SystemNotification
-from simplipy.system.v3 import (
- MAX_ALARM_DURATION,
- MAX_ENTRY_DELAY_AWAY,
- MAX_ENTRY_DELAY_HOME,
- MAX_EXIT_DELAY_AWAY,
- MAX_EXIT_DELAY_HOME,
- MIN_ALARM_DURATION,
- MIN_ENTRY_DELAY_AWAY,
- MIN_EXIT_DELAY_AWAY,
- SystemV3,
- Volume,
-)
from simplipy.websocket import (
EVENT_AUTOMATIC_TEST,
EVENT_CAMERA_MOTION_DETECTED,
@@ -35,55 +22,38 @@ from simplipy.websocket import (
EVENT_USER_INITIATED_TEST,
WebsocketEvent,
)
-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,
CONF_CODE,
CONF_TOKEN,
CONF_USERNAME,
Platform,
)
-from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import (
- ConfigEntryAuthFailed,
- ConfigEntryNotReady,
- HomeAssistantError,
-)
+from homeassistant.core import CoreState, HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
config_validation as cv,
device_registry as dr,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.service import (
- async_register_admin_service,
- verify_domain_control,
-)
+from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import UpdateFailed
from .const import (
- ATTR_ALARM_DURATION,
- ATTR_ALARM_VOLUME,
- ATTR_CHIME_VOLUME,
- ATTR_ENTRY_DELAY_AWAY,
- ATTR_ENTRY_DELAY_HOME,
- ATTR_EXIT_DELAY_AWAY,
- ATTR_EXIT_DELAY_HOME,
ATTR_LAST_EVENT_INFO,
ATTR_LAST_EVENT_SENSOR_NAME,
ATTR_LAST_EVENT_SENSOR_TYPE,
ATTR_LAST_EVENT_TIMESTAMP,
- ATTR_LIGHT,
ATTR_SYSTEM_ID,
- ATTR_VOICE_PROMPT_VOLUME,
DISPATCHER_TOPIC_WEBSOCKET_EVENT,
DOMAIN,
LOGGER,
)
from .coordinator import SimpliSafeDataUpdateCoordinator
+from .services import async_setup_services
from .typing import SystemType
type SimpliSafeConfigEntry = ConfigEntry[SimpliSafe]
@@ -94,9 +64,6 @@ ATTR_LAST_EVENT_SENSOR_SERIAL = "last_event_sensor_serial"
ATTR_LAST_EVENT_TYPE = "last_event_type"
ATTR_LAST_EVENT_TYPE = "last_event_type"
ATTR_MESSAGE = "message"
-ATTR_PIN_LABEL = "label"
-ATTR_PIN_LABEL_OR_VALUE = "label_or_pin"
-ATTR_PIN_VALUE = "pin"
ATTR_TIMESTAMP = "timestamp"
WEBSOCKET_RECONNECT_RETRIES = 3
@@ -114,74 +81,6 @@ PLATFORMS = [
Platform.SENSOR,
]
-VOLUME_MAP = {
- "high": Volume.HIGH,
- "low": Volume.LOW,
- "medium": Volume.MEDIUM,
- "off": Volume.OFF,
-}
-
-SERVICE_NAME_REMOVE_PIN = "remove_pin"
-SERVICE_NAME_SET_PIN = "set_pin"
-SERVICE_NAME_SET_SYSTEM_PROPERTIES = "set_system_properties"
-
-SERVICES = (
- SERVICE_NAME_REMOVE_PIN,
- SERVICE_NAME_SET_PIN,
- SERVICE_NAME_SET_SYSTEM_PROPERTIES,
-)
-
-SERVICE_REMOVE_PIN_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_DEVICE_ID): cv.string,
- vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string,
- }
-)
-
-SERVICE_SET_PIN_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_DEVICE_ID): cv.string,
- vol.Required(ATTR_PIN_LABEL): cv.string,
- vol.Required(ATTR_PIN_VALUE): cv.string,
- },
-)
-
-SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_DEVICE_ID): cv.string,
- vol.Optional(ATTR_ALARM_DURATION): vol.All(
- cv.time_period,
- lambda value: value.total_seconds(),
- vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION),
- ),
- vol.Optional(ATTR_ALARM_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get),
- vol.Optional(ATTR_CHIME_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get),
- vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All(
- cv.time_period,
- lambda value: value.total_seconds(),
- vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY),
- ),
- vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All(
- cv.time_period,
- lambda value: value.total_seconds(),
- vol.Range(max=MAX_ENTRY_DELAY_HOME),
- ),
- vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All(
- cv.time_period,
- lambda value: value.total_seconds(),
- vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY),
- ),
- vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All(
- cv.time_period,
- lambda value: value.total_seconds(),
- vol.Range(max=MAX_EXIT_DELAY_HOME),
- ),
- vol.Optional(ATTR_LIGHT): cv.boolean,
- vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All(
- vol.In(VOLUME_MAP), VOLUME_MAP.get
- ),
- }
-)
WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [
EVENT_AUTOMATIC_TEST,
@@ -193,47 +92,13 @@ WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [
EVENT_USER_INITIATED_TEST,
]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-@callback
-def _async_get_system_for_service_call(
- hass: HomeAssistant, call: ServiceCall
-) -> SystemType:
- """Get the SimpliSafe system related to a service call (by device ID)."""
- device_id = call.data[ATTR_DEVICE_ID]
- device_registry = dr.async_get(hass)
- if (
- alarm_control_panel_device_entry := device_registry.async_get(device_id)
- ) is None:
- raise vol.Invalid("Invalid device ID specified")
-
- assert alarm_control_panel_device_entry.via_device_id
-
- if (
- base_station_device_entry := device_registry.async_get(
- alarm_control_panel_device_entry.via_device_id
- )
- ) is None:
- raise ValueError("No base station registered for alarm control panel")
-
- [system_id_str] = [
- identity[1]
- for identity in base_station_device_entry.identifiers
- if identity[0] == DOMAIN
- ]
- system_id = int(system_id_str)
-
- entry: SimpliSafeConfigEntry | None
- for entry_id in base_station_device_entry.config_entries:
- if (
- (entry := hass.config_entries.async_get_entry(entry_id)) is None
- or entry.domain != DOMAIN
- or entry.state is not ConfigEntryState.LOADED
- ):
- continue
- return entry.runtime_data.systems[system_id]
-
- raise ValueError(f"No system for device ID: {device_id}")
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the integration."""
+ async_setup_services(hass)
+ return True
@callback
@@ -295,7 +160,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -
"""Set up SimpliSafe as config entry."""
_async_standardize_config_entry(hass, entry)
- _verify_domain_control = verify_domain_control(DOMAIN)
websession = aiohttp_client.async_get_clientsession(hass)
try:
@@ -319,64 +183,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- @callback
- def extract_system(
- func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]],
- ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]:
- """Define a decorator to get the correct system for a service call."""
-
- async def wrapper(call: ServiceCall) -> None:
- """Wrap the service function."""
- system = _async_get_system_for_service_call(hass, call)
-
- try:
- await func(call, system)
- except SimplipyError as err:
- raise HomeAssistantError(
- f'Error while executing "{call.service}": {err}'
- ) from err
-
- return wrapper
-
- @_verify_domain_control
- @extract_system
- async def async_remove_pin(call: ServiceCall, system: SystemType) -> None:
- """Remove a PIN."""
- await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE])
-
- @_verify_domain_control
- @extract_system
- async def async_set_pin(call: ServiceCall, system: SystemType) -> None:
- """Set a PIN."""
- await system.async_set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE])
-
- @_verify_domain_control
- @extract_system
- async def async_set_system_properties(
- call: ServiceCall, system: SystemType
- ) -> None:
- """Set one or more system parameters."""
- if not isinstance(system, SystemV3):
- raise HomeAssistantError("Can only set system properties on V3 systems")
-
- await system.async_set_properties(
- {prop: value for prop, value in call.data.items() if prop != ATTR_DEVICE_ID}
- )
-
- for service, method, schema in (
- (SERVICE_NAME_REMOVE_PIN, async_remove_pin, SERVICE_REMOVE_PIN_SCHEMA),
- (SERVICE_NAME_SET_PIN, async_set_pin, SERVICE_SET_PIN_SCHEMA),
- (
- SERVICE_NAME_SET_SYSTEM_PROPERTIES,
- async_set_system_properties,
- SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA,
- ),
- ):
- if hass.services.has_service(DOMAIN, service):
- continue
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- async_register_admin_service(hass, DOMAIN, service, method, schema=schema)
-
current_options = {**entry.options}
async def async_reload_entry(_: HomeAssistant, updated_entry: ConfigEntry) -> None:
@@ -403,15 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -> bool:
"""Unload a SimpliSafe config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
- 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:
- hass.services.async_remove(DOMAIN, service_name)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
class SimpliSafe:
diff --git a/homeassistant/components/simplisafe/services.py b/homeassistant/components/simplisafe/services.py
new file mode 100644
index 000000000000..55c465ec1840
--- /dev/null
+++ b/homeassistant/components/simplisafe/services.py
@@ -0,0 +1,235 @@
+"""Services for SimpliSafe."""
+
+from collections.abc import Callable, Coroutine
+from typing import TYPE_CHECKING, Any
+
+from simplipy.errors import SimplipyError
+from simplipy.system.v3 import (
+ MAX_ALARM_DURATION,
+ MAX_ENTRY_DELAY_AWAY,
+ MAX_ENTRY_DELAY_HOME,
+ MAX_EXIT_DELAY_AWAY,
+ MAX_EXIT_DELAY_HOME,
+ MIN_ALARM_DURATION,
+ MIN_ENTRY_DELAY_AWAY,
+ MIN_EXIT_DELAY_AWAY,
+ SystemV3,
+ Volume,
+)
+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.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.service import (
+ async_register_admin_service,
+ verify_domain_control,
+)
+
+from .const import (
+ ATTR_ALARM_DURATION,
+ ATTR_ALARM_VOLUME,
+ ATTR_CHIME_VOLUME,
+ ATTR_ENTRY_DELAY_AWAY,
+ ATTR_ENTRY_DELAY_HOME,
+ ATTR_EXIT_DELAY_AWAY,
+ ATTR_EXIT_DELAY_HOME,
+ ATTR_LIGHT,
+ ATTR_VOICE_PROMPT_VOLUME,
+ DOMAIN,
+)
+from .typing import SystemType
+
+if TYPE_CHECKING:
+ from . import SimpliSafeConfigEntry
+
+ATTR_PIN_LABEL = "label"
+ATTR_PIN_LABEL_OR_VALUE = "label_or_pin"
+ATTR_PIN_VALUE = "pin"
+
+VOLUME_MAP = {
+ "high": Volume.HIGH,
+ "low": Volume.LOW,
+ "medium": Volume.MEDIUM,
+ "off": Volume.OFF,
+}
+
+SERVICE_NAME_REMOVE_PIN = "remove_pin"
+SERVICE_NAME_SET_PIN = "set_pin"
+SERVICE_NAME_SET_SYSTEM_PROPERTIES = "set_system_properties"
+
+SERVICE_REMOVE_PIN_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_DEVICE_ID): cv.string,
+ vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string,
+ }
+)
+
+SERVICE_SET_PIN_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_DEVICE_ID): cv.string,
+ vol.Required(ATTR_PIN_LABEL): cv.string,
+ vol.Required(ATTR_PIN_VALUE): cv.string,
+ },
+)
+
+SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_DEVICE_ID): cv.string,
+ vol.Optional(ATTR_ALARM_DURATION): vol.All(
+ cv.time_period,
+ lambda value: value.total_seconds(),
+ vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION),
+ ),
+ vol.Optional(ATTR_ALARM_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get),
+ vol.Optional(ATTR_CHIME_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get),
+ vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All(
+ cv.time_period,
+ lambda value: value.total_seconds(),
+ vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY),
+ ),
+ vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All(
+ cv.time_period,
+ lambda value: value.total_seconds(),
+ vol.Range(max=MAX_ENTRY_DELAY_HOME),
+ ),
+ vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All(
+ cv.time_period,
+ lambda value: value.total_seconds(),
+ vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY),
+ ),
+ vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All(
+ cv.time_period,
+ lambda value: value.total_seconds(),
+ vol.Range(max=MAX_EXIT_DELAY_HOME),
+ ),
+ vol.Optional(ATTR_LIGHT): cv.boolean,
+ vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All(
+ vol.In(VOLUME_MAP), VOLUME_MAP.get
+ ),
+ }
+)
+
+_verify_domain_control = verify_domain_control(DOMAIN)
+
+
+@callback
+def _async_get_system_for_service_call(call: ServiceCall) -> SystemType:
+ """Get the SimpliSafe system related to a service call (by device ID)."""
+ device_id = call.data[ATTR_DEVICE_ID]
+ device_registry = dr.async_get(call.hass)
+
+ if (
+ alarm_control_panel_device_entry := device_registry.async_get(device_id)
+ ) is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_device_id",
+ translation_placeholders={"device_id": device_id},
+ )
+
+ if TYPE_CHECKING:
+ assert alarm_control_panel_device_entry.via_device_id
+
+ if (
+ base_station_device_entry := device_registry.async_get(
+ alarm_control_panel_device_entry.via_device_id
+ )
+ ) is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_base_station",
+ translation_placeholders={"device_id": device_id},
+ )
+
+ [system_id_str] = [
+ identity[1]
+ for identity in base_station_device_entry.identifiers
+ if identity[0] == DOMAIN
+ ]
+ system_id = int(system_id_str)
+
+ entry: SimpliSafeConfigEntry | None
+ for entry_id in base_station_device_entry.config_entries:
+ if (
+ (entry := call.hass.config_entries.async_get_entry(entry_id)) is None
+ or entry.domain != DOMAIN
+ or entry.state is not ConfigEntryState.LOADED
+ ):
+ continue
+ return entry.runtime_data.systems[system_id]
+
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_system_for_device",
+ translation_placeholders={"device_id": device_id},
+ )
+
+
+@callback
+def extract_system(
+ func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]],
+) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]:
+ """Define a decorator to get the correct system for a service call."""
+
+ async def wrapper(call: ServiceCall) -> None:
+ """Wrap the service function."""
+ system = _async_get_system_for_service_call(call)
+
+ try:
+ await func(call, system)
+ except SimplipyError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="error_while_executing",
+ translation_placeholders={"service": call.service, "error": str(err)},
+ ) from err
+
+ return wrapper
+
+
+@_verify_domain_control
+@extract_system
+async def async_remove_pin(call: ServiceCall, system: SystemType) -> None:
+ """Remove a PIN."""
+ await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE])
+
+
+@_verify_domain_control
+@extract_system
+async def async_set_pin(call: ServiceCall, system: SystemType) -> None:
+ """Set a PIN."""
+ await system.async_set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE])
+
+
+@_verify_domain_control
+@extract_system
+async def async_set_system_properties(call: ServiceCall, system: SystemType) -> None:
+ """Set one or more system parameters."""
+ if not isinstance(system, SystemV3):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="set_system_properties_not_v3",
+ )
+
+ await system.async_set_properties(
+ {prop: value for prop, value in call.data.items() if prop != ATTR_DEVICE_ID}
+ )
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register services."""
+
+ for service, method, schema in (
+ (SERVICE_NAME_REMOVE_PIN, async_remove_pin, SERVICE_REMOVE_PIN_SCHEMA),
+ (SERVICE_NAME_SET_PIN, async_set_pin, SERVICE_SET_PIN_SCHEMA),
+ (
+ SERVICE_NAME_SET_SYSTEM_PROPERTIES,
+ async_set_system_properties,
+ SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA,
+ ),
+ ):
+ async_register_admin_service(hass, DOMAIN, service, method, schema=schema)
diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json
index a8bbb8679865..2e858766e2e5 100644
--- a/homeassistant/components/simplisafe/strings.json
+++ b/homeassistant/components/simplisafe/strings.json
@@ -27,6 +27,23 @@
}
}
},
+ "exceptions": {
+ "error_while_executing": {
+ "message": "Error while executing \"{service}\": {error}"
+ },
+ "invalid_device_id": {
+ "message": "No device could be found for ID: {device_id}"
+ },
+ "no_base_station": {
+ "message": "No base station could be found for device ID: {device_id}"
+ },
+ "no_system_for_device": {
+ "message": "No SimpliSafe system could be found for device ID: {device_id}"
+ },
+ "set_system_properties_not_v3": {
+ "message": "System properties can only be set on V3 systems."
+ }
+ },
"options": {
"step": {
"init": {
diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py
index 77abd69ac833..694f4e98a6fb 100644
--- a/homeassistant/components/sma/config_flow.py
+++ b/homeassistant/components/sma/config_flow.py
@@ -27,6 +27,11 @@ 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.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_GROUP, DOMAIN, GROUPS
@@ -34,6 +39,39 @@ from .const import CONF_GROUP, DOMAIN, GROUPS
_LOGGER = logging.getLogger(__name__)
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.URL)
+ ),
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+ vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
+ }
+)
+
+
+STEP_DISCOVERY_CONFIRM_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+ vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
+ }
+)
+
+
async def validate_input(
hass: HomeAssistant,
user_input: dict[str, Any],
@@ -130,18 +168,9 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_HOST, default=self._data[CONF_HOST]): cv.string,
- 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,
- }
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=STEP_USER_DATA_SCHEMA,
+ suggested_values=user_input,
),
errors=errors,
)
@@ -172,20 +201,14 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SSL: user_input[CONF_SSL],
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
CONF_GROUP: user_input[CONF_GROUP],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
- data_schema=vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_SSL): cv.boolean,
- vol.Optional(CONF_VERIFY_SSL): cv.boolean,
- vol.Optional(CONF_GROUP): vol.In(GROUPS),
- }
- ),
+ data_schema=STEP_USER_DATA_SCHEMA,
suggested_values=user_input or dict(reconf_entry.data),
),
errors=errors,
@@ -221,7 +244,12 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="reauth_confirm",
data_schema=vol.Schema(
{
- vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
}
),
errors=errors,
@@ -290,17 +318,9 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
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,
- }
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=STEP_DISCOVERY_CONFIRM_DATA_SCHEMA,
+ suggested_values=user_input,
),
description_placeholders={CONF_HOST: self._data[CONF_HOST]},
errors=errors,
diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py
index 82d8e751498d..1eb9559a2b8f 100644
--- a/homeassistant/components/smartthings/__init__.py
+++ b/homeassistant/components/smartthings/__init__.py
@@ -314,9 +314,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
for device_identifier in device_status
):
continue
- device_registry.async_update_device(
- device_entry.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(device_entry.id)
return True
diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py
index 76db28b72bc0..b233cf39ce92 100644
--- a/homeassistant/components/smlight/__init__.py
+++ b/homeassistant/components/smlight/__init__.py
@@ -4,18 +4,17 @@ from pysmlight import Api2
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
-from .bluetooth import async_connect_scanner
+from .bluetooth import async_setup_ble_scanner
from .const import DOMAIN
from .coordinator import (
SmConfigEntry,
SmDataUpdateCoordinator,
SmFirmwareUpdateCoordinator,
SmlightData,
- base_device_info,
)
from .services import async_setup_services
@@ -55,13 +54,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
hass, client.sse.client(), "smlight-sse-client"
)
- if info.ble is not None and info.ble.proxy_enabled:
- device_registry = dr.async_get(hass)
- device = device_registry.async_get_or_create(
- config_entry_id=entry.entry_id,
- **base_device_info(info, client.host),
- )
- entry.async_on_unload(async_connect_scanner(hass, entry, info.model, device.id))
+ if info.ble is not None and (
+ unload_callback := await async_setup_ble_scanner(hass, entry, client, info)
+ ):
+ entry.async_on_unload(unload_callback)
entry.runtime_data = SmlightData(
data=data_coordinator,
diff --git a/homeassistant/components/smlight/bluetooth.py b/homeassistant/components/smlight/bluetooth.py
index a6c7647ca99b..f3e0d52de208 100644
--- a/homeassistant/components/smlight/bluetooth.py
+++ b/homeassistant/components/smlight/bluetooth.py
@@ -1,9 +1,10 @@
"""Bluetooth proxy for SLZB devices using bleak-smlight."""
from functools import partial
+import logging
from bleak_smlight import SLZB_BLE_SERVER_PORT, connect_scanner
-from pysmlight import BleProxyClient
+from pysmlight import Api2, BleProxyClient, Info
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
@@ -11,9 +12,12 @@ from homeassistant.components.bluetooth import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
-from .const import DOMAIN
-from .coordinator import SmConfigEntry
+from .const import CONF_BLE_SCANNER_MODE, DOMAIN, BLEScannerMode
+from .coordinator import SmConfigEntry, base_device_info
+
+_LOGGER = logging.getLogger(__name__)
@callback
@@ -33,6 +37,7 @@ def async_connect_scanner(
entry: SmConfigEntry,
model: str | None,
device_id: str,
+ scanner_mode: BluetoothScanningMode = BluetoothScanningMode.AUTO,
) -> CALLBACK_TYPE:
"""Connect scanner using the external bleak-smlight backend."""
assert entry.unique_id is not None
@@ -44,7 +49,7 @@ def async_connect_scanner(
port=SLZB_BLE_SERVER_PORT,
)
- client_data.scanner.async_set_scanning_mode(BluetoothScanningMode.AUTO)
+ client_data.scanner.async_set_scanning_mode(scanner_mode)
entry.async_create_background_task(
hass,
@@ -65,3 +70,57 @@ def async_connect_scanner(
]
return partial(_async_unload, unload_callbacks, client_data.client)
+
+
+async def async_setup_ble_scanner(
+ hass: HomeAssistant,
+ entry: SmConfigEntry,
+ client: Api2,
+ info: Info,
+) -> CALLBACK_TYPE | None:
+ """Set up the BLE scanner/proxy configuration."""
+ assert info.ble is not None
+
+ scanner_mode = get_ble_scanner_mode(entry, info)
+
+ remote_adapter_enabled = scanner_mode != BLEScannerMode.DISABLED
+
+ if remote_adapter_enabled:
+ if not info.ble.proxy_enabled:
+ _LOGGER.warning(
+ "SMLIGHT BLE proxy is enabled in Home Assistant options but disabled on the device. "
+ "Please reconfigure the integration options to align settings"
+ )
+ return None
+
+ device_registry = dr.async_get(hass)
+ device = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **base_device_info(info, client.host),
+ )
+ return async_connect_scanner(
+ hass,
+ entry,
+ info.model,
+ device.id,
+ BluetoothScanningMode(scanner_mode),
+ )
+
+ return None
+
+
+@callback
+def get_ble_scanner_mode(
+ entry: SmConfigEntry,
+ info: Info,
+) -> BLEScannerMode:
+ """Get the BLE scanner mode config or default."""
+ if info.ble is None:
+ return BLEScannerMode.DISABLED
+
+ return BLEScannerMode(
+ entry.options.get(
+ CONF_BLE_SCANNER_MODE,
+ BLEScannerMode.AUTO if info.ble.proxy_enabled else BLEScannerMode.DISABLED,
+ )
+ )
diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py
index 6f8ea41980d2..3e19b4690b7d 100644
--- a/homeassistant/components/smlight/button.py
+++ b/homeassistant/components/smlight/button.py
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
+from .const import DOMAIN, ZWAVE_TYPES
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
@@ -129,6 +129,15 @@ class SmButton(SmEntity, ButtonEntity):
button = f"_{idx}" if idx else ""
self._attr_unique_id = f"{coordinator.unique_id}-{description.key}{button}"
+ if (
+ idx < len(coordinator.data.info.radios)
+ and coordinator.data.info.radios[idx].zb_type in ZWAVE_TYPES
+ ):
+ if description.key == "zigbee_restart":
+ self._attr_translation_key = "z_wave_restart"
+ elif description.key == "zigbee_flash_mode":
+ self._attr_translation_key = "z_wave_flash_mode"
+
@override
async def async_press(self) -> None:
"""Trigger button press."""
diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py
index ce081b35ff43..00cda638dbd5 100644
--- a/homeassistant/components/smlight/config_flow.py
+++ b/homeassistant/components/smlight/config_flow.py
@@ -13,14 +13,23 @@ from homeassistant.config_entries import (
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
+ OptionsFlowWithReload,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.selector import (
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
-from .const import DOMAIN
+from .bluetooth import get_ble_scanner_mode
+from .const import CONF_BLE_SCANNER_MODE, DOMAIN, BLEScannerMode
+from .coordinator import SmConfigEntry
STEP_USER_DATA_SCHEMA = vol.Schema(
{
@@ -35,6 +44,25 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema(
}
)
+BLE_SCANNER_OPTIONS = [
+ BLEScannerMode.DISABLED,
+ BLEScannerMode.AUTO,
+ BLEScannerMode.ACTIVE,
+ BLEScannerMode.PASSIVE,
+]
+
+BLE_SCANNER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_BLE_SCANNER_MODE): SelectSelector(
+ SelectSelectorConfig(
+ options=BLE_SCANNER_OPTIONS,
+ translation_key=CONF_BLE_SCANNER_MODE,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ )
+ }
+)
+
class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SMLIGHT Zigbee."""
@@ -275,3 +303,86 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
assert info.model is not None
title = self._device_name or info.model
return self.async_create_entry(title=title, data=user_input)
+
+ @staticmethod
+ @callback
+ @override
+ def async_get_options_flow(
+ config_entry: SmConfigEntry,
+ ) -> OptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return OptionsFlowHandler()
+
+
+class OptionsFlowHandler(OptionsFlowWithReload):
+ """Handle options flow for SMLIGHT."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle options flow."""
+ errors: dict[str, str] = {}
+
+ if not hasattr(self.config_entry, "runtime_data"):
+ errors["base"] = "cannot_connect"
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ BLE_SCANNER_SCHEMA,
+ user_input or {CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED},
+ ),
+ errors=errors,
+ )
+
+ coordinator = self.config_entry.runtime_data.data
+ info = coordinator.data.info
+
+ if info.ble is None:
+ return await self.async_step_no_settings()
+
+ if user_input is not None:
+ scanner_mode = BLEScannerMode(user_input[CONF_BLE_SCANNER_MODE])
+ user_input[CONF_BLE_SCANNER_MODE] = scanner_mode
+ current_mode = get_ble_scanner_mode(self.config_entry, info)
+
+ if (scanner_mode == BLEScannerMode.DISABLED) != (
+ current_mode == BLEScannerMode.DISABLED
+ ):
+ try:
+ await coordinator.client.set_ble_proxy(
+ scanner_mode != BLEScannerMode.DISABLED
+ )
+ except SmlightConnectionError:
+ errors["base"] = "cannot_connect"
+ except SmlightAuthError:
+ errors["base"] = "invalid_auth"
+ self.config_entry.async_start_reauth(self.hass)
+
+ if not errors:
+ return self.async_create_entry(title="", data=user_input)
+
+ suggested_values = {
+ CONF_BLE_SCANNER_MODE: get_ble_scanner_mode(self.config_entry, info)
+ }
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ BLE_SCANNER_SCHEMA, user_input or suggested_values
+ ),
+ errors=errors,
+ )
+
+ async def async_step_no_settings(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle options for devices without settings."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data={})
+
+ coordinator = self.config_entry.runtime_data.data
+ return self.async_show_form(
+ step_id="no_settings",
+ data_schema=vol.Schema({}),
+ description_placeholders={"model": coordinator.data.info.model},
+ )
diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py
index 0a45363f8adf..3dc91ebd312e 100644
--- a/homeassistant/components/smlight/const.py
+++ b/homeassistant/components/smlight/const.py
@@ -1,8 +1,11 @@
"""Constants for the SMLIGHT Zigbee integration."""
from datetime import timedelta
+from enum import StrEnum
import logging
+from pysmlight.const import ZB_TYPES
+
DOMAIN = "smlight"
ATTR_MANUFACTURER = "SMLIGHT"
@@ -14,3 +17,17 @@ LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(seconds=300)
SCAN_INTERNET_INTERVAL = timedelta(minutes=15)
UPTIME_DEVIATION = timedelta(seconds=5)
+
+CONF_BLE_SCANNER_MODE = "ble_scanner_mode"
+
+
+class BLEScannerMode(StrEnum):
+ """BLE scanner mode."""
+
+ DISABLED = "disabled"
+ AUTO = "auto"
+ ACTIVE = "active"
+ PASSIVE = "passive"
+
+
+ZWAVE_TYPES = tuple(k for k, v in ZB_TYPES.items() if v.lower().startswith("zwave"))
diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py
index 0e9859473128..7567ba60d116 100644
--- a/homeassistant/components/smlight/infrared.py
+++ b/homeassistant/components/smlight/infrared.py
@@ -2,11 +2,17 @@
from typing import override
+from pysmlight.const import Events as SmEvents
from pysmlight.exceptions import SmlightError
from pysmlight.models import IRPayload
-from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
-from homeassistant.core import HomeAssistant
+from homeassistant.components.infrared import (
+ InfraredCommand,
+ InfraredEmitterEntity,
+ InfraredReceivedSignal,
+ InfraredReceiverEntity,
+)
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -26,14 +32,17 @@ async def async_setup_entry(
coordinator = entry.runtime_data.data
if coordinator.data.info.has_peripherals:
- async_add_entities([SmInfraredEntity(coordinator)])
+ async_add_entities(
+ [
+ SmInfraredEntity(coordinator),
+ SmInfraredReceiverEntity(coordinator),
+ ]
+ )
class SmInfraredEntity(SmEntity, InfraredEmitterEntity):
"""Representation of a SLZB-Ultima infrared emitter."""
- _attr_translation_key = "infrared_emitter"
-
def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
"""Initialize the SLZB-Ultima infrared."""
super().__init__(coordinator)
@@ -59,3 +68,27 @@ class SmInfraredEntity(SmEntity, InfraredEmitterEntity):
translation_key="send_ir_code_failed",
translation_placeholders={"error": str(err)},
) from err
+
+
+class SmInfraredReceiverEntity(SmEntity, InfraredReceiverEntity):
+ """Representation of a SLZB-Ultima infrared receiver."""
+
+ def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
+ """Initialize the SLZB-Ultima infrared receiver."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.unique_id}-receiver"
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """Register SSE callbacks when entity is added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.coordinator.client.sse.register_callback(
+ SmEvents.IR_CODE, self._handle_ir_code
+ )
+ )
+
+ @callback
+ def _handle_ir_code(self, timings: list[int]) -> None:
+ """Handle received IR code."""
+ self._handle_received_signal(InfraredReceivedSignal(timings=timings))
diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json
index e43028669e73..8a818fccfd8f 100644
--- a/homeassistant/components/smlight/manifest.json
+++ b/homeassistant/components/smlight/manifest.json
@@ -13,7 +13,7 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "platinum",
- "requirements": ["pysmlight==0.5.2", "bleak-smlight==1.1.0"],
+ "requirements": ["pysmlight==0.5.3", "bleak-smlight==1.1.0"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py
index 5335e0aec9ba..69927ddf0668 100644
--- a/homeassistant/components/smlight/sensor.py
+++ b/homeassistant/components/smlight/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
-from .const import UPTIME_DEVIATION
+from .const import UPTIME_DEVIATION, ZWAVE_TYPES
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
@@ -159,7 +159,8 @@ async def async_setup_entry(
entities.extend(
SmInfoSensorEntity(coordinator, RADIO_INFO, idx)
- for idx, _ in enumerate(coordinator.data.info.radios)
+ for idx, radio in enumerate(coordinator.data.info.radios)
+ if radio.zb_type not in ZWAVE_TYPES
)
if coordinator.data.sensors.zb_temp2 is not None:
diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json
index ad83fc40d7e1..6de1f48ecaed 100644
--- a/homeassistant/components/smlight/strings.json
+++ b/homeassistant/components/smlight/strings.json
@@ -84,6 +84,12 @@
"reconnect_zigbee_router": {
"name": "Reconnect Zigbee router"
},
+ "z_wave_flash_mode": {
+ "name": "Z-Wave flash mode"
+ },
+ "z_wave_restart": {
+ "name": "Z-Wave restart"
+ },
"zigbee_flash_mode": {
"name": "Zigbee flash mode"
},
@@ -91,11 +97,6 @@
"name": "Zigbee restart"
}
},
- "infrared": {
- "infrared_emitter": {
- "name": "IR emitter"
- }
- },
"light": {
"ambilight": {
"name": "Ambilight"
@@ -165,6 +166,9 @@
"core_update": {
"name": "Core firmware"
},
+ "z_wave_update": {
+ "name": "Z-Wave firmware"
+ },
"zigbee_update": {
"name": "Zigbee firmware"
}
@@ -196,6 +200,36 @@
"title": "SLZB core firmware update required"
}
},
+ "options": {
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "ble_scanner_mode": "Bluetooth scanner mode"
+ },
+ "data_description": {
+ "ble_scanner_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly."
+ },
+ "description": "Configure SLZB Bluetooth settings. Switching between Disabled and another mode will reboot the device to apply the new configuration."
+ },
+ "no_settings": {
+ "description": "This SMLIGHT device ({model}) does not support settings."
+ }
+ }
+ },
+ "selector": {
+ "ble_scanner_mode": {
+ "options": {
+ "active": "Active (uses more device battery, fastest updates)",
+ "auto": "Auto (recommended, saves device battery)",
+ "disabled": "[%key:common::state::disabled%]",
+ "passive": "Passive (lowest device battery use, some details may be missing)"
+ }
+ }
+ },
"services": {
"play_rtttl": {
"description": "Play an RTTTL melody on the SMLIGHT device buzzer.",
diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py
index 899ccfaa6468..37b738ab47e8 100644
--- a/homeassistant/components/smlight/update.py
+++ b/homeassistant/components/smlight/update.py
@@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN, LOGGER
+from .const import DOMAIN, LOGGER, ZWAVE_TYPES
from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData
from .entity import SmEntity
@@ -77,7 +77,8 @@ async def async_setup_entry(
entities.extend(
SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx)
- for idx, _ in enumerate(radios)
+ for idx, radio in enumerate(radios)
+ if radio.zb_type != -1
)
async_add_entities(entities)
@@ -114,6 +115,14 @@ class SmUpdateEntity(SmEntity, UpdateEntity):
self._unload: list[Callable] = []
self.idx = idx
+ if (
+ (data := coordinator.data)
+ and idx < len(data.info.radios)
+ and data.info.radios[idx].zb_type in ZWAVE_TYPES
+ ):
+ if description.key == "zigbee_update":
+ self._attr_translation_key = "z_wave_update"
+
@override
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@@ -173,7 +182,16 @@ class SmUpdateEntity(SmEntity, UpdateEntity):
def release_notes(self) -> str | None:
"""Return release notes for firmware."""
if "zigbee" in self.entity_description.key:
- notes = f"### {'ZNP' if self.idx else 'EZSP'} Firmware\n\n"
+ radio_desc = "Zigbee"
+ if (data := self.coordinator.data) and self.idx < len(data.info.radios):
+ radio = data.info.radios[self.idx]
+ if radio.zb_type in ZWAVE_TYPES:
+ radio_desc = "Z-Wave"
+ elif radio.zb_hw and "EFR32" in radio.zb_hw:
+ radio_desc = "EZSP"
+ elif radio.zb_hw and "CC2" in radio.zb_hw:
+ radio_desc = "ZNP"
+ notes = f"### {radio_desc} Firmware\n\n"
else:
notes = "### Core Firmware\n\n"
diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py
index b38cfef48c85..28e43b3c9388 100644
--- a/homeassistant/components/smtp/config_flow.py
+++ b/homeassistant/components/smtp/config_flow.py
@@ -10,6 +10,8 @@ from typing import Any, override
import voluptuous as vol
+from homeassistant import data_entry_flow
+from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.config_entries import (
SOURCE_USER,
ConfigFlow,
@@ -33,7 +35,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import callback
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
@@ -58,17 +60,34 @@ from .const import (
DEFAULT_TIMEOUT,
DOMAIN,
ENCRYPTION_OPTIONS,
+ SECTION_OPTIONS,
SUBENTRY_TYPE_RECIPIENT,
)
_LOGGER = logging.getLogger(__name__)
+OPTIONS_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All(
+ NumberSelector(
+ NumberSelectorConfig(
+ min=1,
+ max=1800,
+ step=1,
+ unit_of_measurement=UnitOfTime.SECONDS,
+ mode=NumberSelectorMode.BOX,
+ )
+ ),
+ vol.Coerce(int),
+ )
+ }
+)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_SENDER): TextSelector(
TextSelectorConfig(
- type=TextSelectorType.TEXT,
+ type=TextSelectorType.EMAIL,
autocomplete="email",
),
),
@@ -114,23 +133,6 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema(
}
)
-OPTIONS_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All(
- NumberSelector(
- NumberSelectorConfig(
- min=1,
- max=1800,
- step=1,
- unit_of_measurement=UnitOfTime.SECONDS,
- mode=NumberSelectorMode.BOX,
- )
- ),
- vol.Coerce(int),
- )
- }
-)
-
class MailConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SMTP."""
@@ -165,16 +167,29 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_USERNAME: user_input.get(CONF_USERNAME),
}
)
- errors = await self.hass.async_add_executor_job(validate_input, user_input)
+ entry_data = user_input.copy()
+ options = entry_data.pop(SECTION_OPTIONS)
+ errors = await self.hass.async_add_executor_job(
+ validate_input, entry_data, options
+ )
if not errors:
return self.async_create_entry(
- title=user_input.get(CONF_SENDER_NAME, user_input[CONF_SENDER]),
- data=user_input,
+ title=entry_data.get(CONF_SENDER_NAME, entry_data[CONF_SENDER]),
+ data=entry_data,
+ options=options,
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
- data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
+ data_schema=STEP_USER_DATA_SCHEMA.extend(
+ {
+ vol.Required(SECTION_OPTIONS): data_entry_flow.section(
+ OPTIONS_SCHEMA,
+ {"collapsed": True},
+ ),
+ }
+ ),
+ suggested_values=user_input,
),
errors=errors,
)
@@ -208,7 +223,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_USERNAME: user_input.get(CONF_USERNAME),
}
)
- errors = await self.hass.async_add_executor_job(validate_input, user_input)
+ errors = await self.hass.async_add_executor_job(
+ validate_input, user_input, dict(entry.options)
+ )
if not errors:
return self.async_update_and_abort(
entry,
@@ -239,7 +256,7 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
errors = await self.hass.async_add_executor_job(
- validate_input, {**entry.data, **user_input}
+ validate_input, {**entry.data, **user_input}, dict(entry.options)
)
if not errors:
return self.async_update_and_abort(
@@ -262,7 +279,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
options = {CONF_TIMEOUT: import_info.pop(CONF_TIMEOUT, DEFAULT_TIMEOUT)}
self._async_abort_entries_match(import_info)
- errors = await self.hass.async_add_executor_job(validate_input, import_info)
+ errors = await self.hass.async_add_executor_job(
+ validate_input, import_info, options
+ )
if not errors:
title = (
import_info.get(CONF_NAME)
@@ -287,7 +306,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason=errors["base"])
-def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
+def validate_input(
+ user_input: dict[str, Any], options: dict[str, Any]
+) -> dict[str, str]:
"""Validate the user input allows us to connect."""
errors: dict[str, str] = {}
ssl_context = create_client_context() if user_input[CONF_VERIFY_SSL] else None
@@ -297,12 +318,14 @@ def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
mail = SMTP_SSL(
user_input[CONF_SERVER],
user_input[CONF_PORT],
- timeout=DEFAULT_TIMEOUT,
+ timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
context=ssl_context,
)
else:
mail = SMTP(
- user_input[CONF_SERVER], user_input[CONF_PORT], timeout=DEFAULT_TIMEOUT
+ user_input[CONF_SERVER],
+ user_input[CONF_PORT],
+ timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
)
mail.ehlo_or_helo_if_needed()
if user_input[CONF_ENCRYPTION] == "starttls":
@@ -351,7 +374,7 @@ class RecipientSubentryFlowHandler(ConfigSubentryFlow):
vol.Optional(CONF_NAME): cv.string,
vol.Required(CONF_RECIPIENT): TextSelector(
TextSelectorConfig(
- type=TextSelectorType.TEXT,
+ type=TextSelectorType.EMAIL,
autocomplete="email",
),
),
@@ -359,6 +382,55 @@ class RecipientSubentryFlowHandler(ConfigSubentryFlow):
),
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure flow to update a recipient."""
+
+ entry = self._get_entry()
+ subentry = self._get_reconfigure_subentry()
+
+ if user_input is not None:
+ old_unique_id = subentry.unique_id
+ result = self.async_update_and_abort(
+ entry,
+ subentry=subentry,
+ title=(
+ user_input[CONF_RECIPIENT]
+ if subentry.title == old_unique_id
+ else subentry.title
+ ),
+ data_updates={},
+ unique_id=user_input[CONF_RECIPIENT],
+ )
+ if result.get("reason") == "reconfigure_successful" and (
+ entity := er.async_get(self.hass).async_get_entity_id(
+ NOTIFY_DOMAIN, DOMAIN, f"{entry.entry_id}_{old_unique_id}"
+ )
+ ):
+ er.async_get(self.hass).async_update_entity(
+ entity,
+ new_unique_id=f"{entry.entry_id}_{user_input[CONF_RECIPIENT]}",
+ )
+ return result
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_RECIPIENT): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.EMAIL,
+ autocomplete="email",
+ ),
+ )
+ }
+ ),
+ suggested_values={CONF_RECIPIENT: subentry.unique_id},
+ ),
+ )
+
class OptionsFlowHandler(OptionsFlow):
"""Handle options flow."""
diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py
index dc9fccd3d5ab..78fb8d99cf27 100644
--- a/homeassistant/components/smtp/const.py
+++ b/homeassistant/components/smtp/const.py
@@ -11,6 +11,7 @@ ATTR_SENDER_NAME: Final = "sender_name"
CONF_ENCRYPTION: Final = "encryption"
CONF_SERVER: Final = "server"
CONF_SENDER_NAME: Final = "sender_name"
+SECTION_OPTIONS: Final = "options"
DEFAULT_HOST: Final = "localhost"
DEFAULT_PORT: Final = 587
diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py
index 15b65b36ea39..325f121575e3 100644
--- a/homeassistant/components/smtp/notify.py
+++ b/homeassistant/components/smtp/notify.py
@@ -201,6 +201,9 @@ class MailNotifyEntity(NotifyEntity):
msg["From"] = email.utils.formataddr(
(self._entry.data.get(CONF_SENDER_NAME), self._entry.data[CONF_SENDER])
)
+ msg["To"] = email.utils.formataddr(
+ (self._subentry.title, self._subentry.unique_id)
+ )
msg["X-Mailer"] = "Home Assistant"
msg["Date"] = email.utils.format_datetime(dt_util.now())
msg["Message-Id"] = email.utils.make_msgid()
@@ -312,21 +315,37 @@ class MailNotificationService(SmtpClient, BaseNotificationService):
def _send_email(self, msg: MIMEMultipart | MIMEText, recipients: list[str]) -> None:
"""Send the message."""
mail = self.connect()
- for _ in range(self.tries):
+ for attempt in range(self.tries):
try:
mail.sendmail(self._sender, recipients, msg.as_string())
break
- except SMTPServerDisconnected:
+ except SMTPServerDisconnected as e:
+ with suppress(SMTPException):
+ mail.quit()
+ if attempt == self.tries - 1:
+ _LOGGER.debug("Full exception:", exc_info=True)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="send_mail_connection_error",
+ ) from e
_LOGGER.warning(
- "SMTPServerDisconnected sending mail: retrying connection"
+ "SMTPServerDisconnected sending mail: retrying connection",
+ exc_info=_LOGGER.isEnabledFor(logging.DEBUG),
)
- with suppress(SMTPException):
- mail.quit()
mail = self.connect()
- except SMTPException:
- _LOGGER.warning("SMTPException sending mail: retrying connection")
+ except SMTPException as e:
with suppress(SMTPException):
mail.quit()
+ if attempt == self.tries - 1:
+ _LOGGER.debug("Full exception:", exc_info=True)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="send_mail_connection_error",
+ ) from e
+ _LOGGER.warning(
+ "SMTPException sending mail: retrying connection",
+ exc_info=_LOGGER.isEnabledFor(logging.DEBUG),
+ )
mail = self.connect()
with suppress(SMTPException):
mail.quit()
diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json
index 1c6a8cf6c2c5..c48c4798e429 100644
--- a/homeassistant/components/smtp/strings.json
+++ b/homeassistant/components/smtp/strings.json
@@ -67,6 +67,17 @@
"server": "Hostname or IP address of the SMTP server. For example, `smtp.example.com`.",
"username": "Username used to authenticate with the SMTP server.",
"verify_ssl": "Enable certificate verification for secure SSL/TLS connections."
+ },
+ "sections": {
+ "options": {
+ "data": {
+ "timeout": "[%key:component::smtp::options::step::init::data::timeout%]"
+ },
+ "data_description": {
+ "timeout": "[%key:component::smtp::options::step::init::data_description::timeout%]"
+ },
+ "name": "Additional options"
+ }
}
}
}
@@ -74,13 +85,24 @@
"config_subentries": {
"recipient": {
"abort": {
- "already_configured": "Recipient is already configured"
+ "already_configured": "Recipient is already configured",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "Recipient",
"initiate_flow": {
"user": "Add recipient"
},
"step": {
+ "reconfigure": {
+ "data": {
+ "recipient": "[%key:common::config_flow::data::email%]"
+ },
+ "data_description": {
+ "recipient": "[%key:component::smtp::config_subentries::recipient::step::user::data_description::recipient%]"
+ },
+ "description": "Update the recipient email address.",
+ "title": "Reconfigure recipient"
+ },
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py
index 7ec6eb89b6f5..19db35212796 100644
--- a/homeassistant/components/snooz/fan.py
+++ b/homeassistant/components/snooz/fan.py
@@ -14,7 +14,11 @@ from pysnooz.commands import (
)
import voluptuous as vol
-from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
+from homeassistant.components.fan import (
+ FanEntity,
+ FanEntityFeature,
+ FanEntityStateAttribute,
+)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -109,7 +113,9 @@ class SnoozFan(FanEntity, RestoreEntity):
self._is_on = last_state.state == STATE_ON
else:
self._is_on = None
- self._percentage = last_state.attributes.get(ATTR_PERCENTAGE)
+ self._percentage = last_state.attributes.get(
+ FanEntityStateAttribute.PERCENTAGE
+ )
self.async_on_remove(self._async_subscribe_to_device_change())
diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py
index 27d3d3eadc0b..1678eac5e599 100644
--- a/homeassistant/components/sonos/__init__.py
+++ b/homeassistant/components/sonos/__init__.py
@@ -15,7 +15,7 @@ from aiohttp import ClientError
from requests.exceptions import HTTPError, Timeout
from soco import events_asyncio, zonegroupstate
import soco.config as soco_config
-from soco.core import SoCo
+from soco.core import SoCo, soco_reset
from soco.events_base import Event as SonosEvent, SubscriptionBase
from soco.exceptions import SoCoException
import voluptuous as vol
@@ -114,6 +114,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SonosConfigEntry) -> bool:
"""Set up Sonos from a config entry."""
+ _LOGGER.debug("Setting up Sonos config entry: %s", entry.entry_id)
+ soco_reset()
soco_config.EVENTS_MODULE = events_asyncio
soco_config.REQUEST_TIMEOUT = 9.5
soco_config.ZGT_EVENT_FALLBACK = False
@@ -153,6 +155,8 @@ async def async_unload_entry(
config_entry, PLATFORMS
)
await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown()
+ soco_reset()
+ _LOGGER.debug("Sonos config entry unloaded: %s", config_entry.entry_id)
return unload_ok
@@ -492,9 +496,22 @@ class SonosDiscoveryManager:
None,
)
if not known_speaker:
+ try:
+ uid = await self.hass.async_add_executor_job(getattr, soco, "uid")
+ except HTTPError as err:
+ await self._process_http_connection_error(err, ip_addr)
+ continue
+ except (
+ OSError,
+ SoCoException,
+ Timeout,
+ TimeoutError,
+ ) as ex:
+ _LOGGER.warning("Could not get Sonos uid from %s: %s", ip_addr, ex)
+ continue
try:
await self._async_handle_discovery_message(
- soco.uid,
+ uid,
ip_addr,
"manual zone scan",
)
@@ -511,7 +528,7 @@ class SonosDiscoveryManager:
# Only send the message if the ping was successful.
async_dispatcher_send(
self.hass,
- f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}",
+ f"{SONOS_SPEAKER_ACTIVITY}-{known_speaker.uid}",
"manual zone scan",
)
except SonosUpdateError:
diff --git a/homeassistant/components/sonos/button.py b/homeassistant/components/sonos/button.py
new file mode 100644
index 000000000000..c286a9363c9f
--- /dev/null
+++ b/homeassistant/components/sonos/button.py
@@ -0,0 +1,50 @@
+"""Button entities for Sonos."""
+
+from typing import override
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import SONOS_CREATE_BUTTON
+from .entity import SonosEntity
+from .helpers import SonosConfigEntry
+from .speaker import SonosSpeaker
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: SonosConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Sonos button entities from a config entry."""
+
+ @callback
+ def async_create_entities(speaker: SonosSpeaker) -> None:
+ """Handle device discovery and create button entities."""
+ async_add_entities([SonosCancelAnnouncementButton(speaker, config_entry)])
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, SONOS_CREATE_BUTTON, async_create_entities)
+ )
+
+
+class SonosCancelAnnouncementButton(SonosEntity, ButtonEntity):
+ """Button to cancel the current Sonos announcement."""
+
+ _attr_translation_key = "cancel_announcement"
+
+ def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
+ """Initialize the cancel announcement button."""
+ super().__init__(speaker, config_entry)
+ self._attr_unique_id = f"{self.soco.uid}-cancel_announcement"
+
+ @override
+ async def _async_fallback_poll(self) -> None:
+ """No-op: button state does not need polling."""
+
+ @override
+ async def async_press(self) -> None:
+ """Cancel the current announcement audio clip."""
+ await self.speaker.async_cancel_announcement()
diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py
index 3142e72e6854..07d7d11ea461 100644
--- a/homeassistant/components/sonos/const.py
+++ b/homeassistant/components/sonos/const.py
@@ -11,6 +11,7 @@ DOMAIN = "sonos"
DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager"
PLATFORMS = [
Platform.BINARY_SENSOR,
+ Platform.BUTTON,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SELECT,
@@ -159,6 +160,7 @@ PLAYABLE_MEDIA_TYPES = [
SONOS_CHECK_ACTIVITY = "sonos_check_activity"
SONOS_CREATE_ALARM = "sonos_create_alarm"
+SONOS_CREATE_BUTTON = "sonos_create_button"
SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor"
SONOS_CREATE_BATTERY = "sonos_create_battery"
SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor"
diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py
index 2b4df23b4ccc..db7229c2e15c 100644
--- a/homeassistant/components/sonos/helpers.py
+++ b/homeassistant/components/sonos/helpers.py
@@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import dispatcher_send
-from .const import SONOS_SPEAKER_ACTIVITY
+from .const import DOMAIN, SONOS_SPEAKER_ACTIVITY
from .exception import SonosUpdateError
if TYPE_CHECKING:
@@ -30,6 +30,8 @@ if TYPE_CHECKING:
UID_PREFIX = "RINCON_"
UID_POSTFIX = "01400"
+UPNP_ERROR_COMMAND_FAILED = "800"
+
_LOGGER = logging.getLogger(__name__)
type _SonosEntitiesType = (
@@ -76,8 +78,24 @@ def soco_error[_T: _SonosEntitiesType, **_P, _R](
if (target := _find_target_identifier(self, args_soco)) is None:
raise RuntimeError("Unexpected use of soco_error") from err
- message = f"Error calling {function} on {target}: {err}"
- raise SonosUpdateError(message) from err
+ translation_key = "call_failed"
+ placeholders = {
+ "target": target,
+ "error": str(err),
+ }
+
+ if error_code is not None:
+ translation_key = "upnp_call_failed"
+ placeholders["error_code"] = str(error_code)
+
+ if str(error_code) == UPNP_ERROR_COMMAND_FAILED:
+ translation_key = "upnp_call_failed_music_service_unavailable"
+
+ raise SonosUpdateError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders=placeholders,
+ ) from err
dispatch_soco = args_soco or self.soco # type: ignore[union-attr]
dispatcher_send(
diff --git a/homeassistant/components/sonos/icons.json b/homeassistant/components/sonos/icons.json
index e28e4c305a99..2c16c854be9a 100644
--- a/homeassistant/components/sonos/icons.json
+++ b/homeassistant/components/sonos/icons.json
@@ -5,6 +5,11 @@
"default": "mdi:microphone"
}
},
+ "button": {
+ "cancel_announcement": {
+ "default": "mdi:cancel"
+ }
+ },
"sensor": {
"audio_input_format": {
"default": "mdi:import"
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index 001f0c9e220e..90d59e0c7db4 100644
--- a/homeassistant/components/sonos/manifest.json
+++ b/homeassistant/components/sonos/manifest.json
@@ -2,7 +2,7 @@
"domain": "sonos",
"name": "Sonos",
"after_dependencies": ["plex", "spotify", "zeroconf", "media_source"],
- "codeowners": ["@jjlawren", "@peterager"],
+ "codeowners": ["@peterager", "@jjlawren"],
"config_flow": true,
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/sonos",
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index d1f5fb1b2cc4..94de448dc6ae 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -17,6 +17,7 @@ from soco.core import (
from soco.data_structures import DidlFavorite, DidlMusicTrack
from soco.exceptions import SoCoException
from soco.ms_data_structures import MusicServiceItem
+from sonos_websocket import CLIP_ID_KEY
from sonos_websocket.exception import SonosWebsocketError
from homeassistant.components import media_source, spotify
@@ -528,8 +529,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
)
_LOGGER.debug("Playing %s using websocket audioclip", media_id)
try:
+ self.speaker.last_announce_id = None
assert self.speaker.websocket
- response, _ = await self.speaker.websocket.play_clip(
+ response, data = await self.speaker.websocket.play_clip(
async_process_play_media_url(self.hass, media_id),
volume=volume,
)
@@ -538,6 +540,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
f"Error when calling Sonos websocket: {exc}"
) from exc
if response.get("success"):
+ if data:
+ self.speaker.last_announce_id = data.get(CLIP_ID_KEY)
return
if response.get("type") in ANNOUNCE_NOT_SUPPORTED_ERRORS:
# If the speaker does not support announce do not raise and
diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py
index f55204491156..24cd43bbcb3e 100644
--- a/homeassistant/components/sonos/speaker.py
+++ b/homeassistant/components/sonos/speaker.py
@@ -17,10 +17,11 @@ from soco.plugins.plex import PlexPlugin
from soco.plugins.sharelink import ShareLinkPlugin
from soco.snapshot import Snapshot
from sonos_websocket import SonosWebsocket
+from sonos_websocket.exception import SonosWebsocketError
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
@@ -44,6 +45,7 @@ from .const import (
SONOS_CREATE_ALARM,
SONOS_CREATE_AUDIO_FORMAT_SENSOR,
SONOS_CREATE_BATTERY,
+ SONOS_CREATE_BUTTON,
SONOS_CREATE_LEVELS,
SONOS_CREATE_MEDIA_PLAYER,
SONOS_CREATE_MIC_SENSOR,
@@ -186,6 +188,9 @@ class SonosSpeaker:
self.snapshot_group: list[SonosSpeaker] = []
self._group_members_missing: set[str] = set()
+ # Announcement tracking
+ self.last_announce_id: str | None = None
+
async def async_setup(
self,
entry: SonosConfigEntry,
@@ -261,6 +266,7 @@ class SonosSpeaker:
dispatches.append((SONOS_CREATE_SELECTS, self))
dispatches.append((SONOS_CREATE_SWITCHES, self))
+ dispatches.append((SONOS_CREATE_BUTTON, self))
dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self))
dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid))
@@ -1294,6 +1300,35 @@ class SonosSpeaker:
any_speaker = next(iter(config_entry.runtime_data.discovered.values()))
any_speaker.soco.zone_group_state.clear_cache()
+ async def async_cancel_announcement(self) -> None:
+ """Cancel the current announcement audio clip."""
+ if self.last_announce_id is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="cancel_announcement_no_id",
+ )
+ if not self.websocket:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="announcement_connection_error",
+ translation_placeholders={"error": "websocket not available"},
+ )
+ try:
+ response, _ = await self.websocket.cancel_clip(self.last_announce_id)
+ except SonosWebsocketError as exc:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="announcement_connection_error",
+ translation_placeholders={"error": str(exc)},
+ ) from exc
+ if not response.get("success"):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cancel_announcement_error",
+ translation_placeholders={"response": str(response)},
+ )
+ self.last_announce_id = None
+
#
# Media and playback state handlers
#
diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json
index f2e01da70fa3..82c99a7e1d23 100644
--- a/homeassistant/components/sonos/strings.json
+++ b/homeassistant/components/sonos/strings.json
@@ -18,6 +18,11 @@
"name": "Microphone"
}
},
+ "button": {
+ "cancel_announcement": {
+ "name": "Cancel announcement"
+ }
+ },
"number": {
"audio_delay": {
"name": "Audio delay"
@@ -109,6 +114,18 @@
"announce_media_error": {
"message": "Announcing clip {media_id} failed {response}"
},
+ "announcement_connection_error": {
+ "message": "Failed to reach Sonos speaker for announcement: {error}"
+ },
+ "call_failed": {
+ "message": "Error on {target}: {error}"
+ },
+ "cancel_announcement_error": {
+ "message": "Cancelling announcement failed: {response}"
+ },
+ "cancel_announcement_no_id": {
+ "message": "No active announcement to cancel"
+ },
"entity_not_found": {
"message": "Entity {entity_id} not found."
},
@@ -141,6 +158,12 @@
},
"toggle_failed": {
"message": "Could not toggle {entity_id}."
+ },
+ "upnp_call_failed": {
+ "message": "Error on {target} (UPnP error code {error_code}): {error}"
+ },
+ "upnp_call_failed_music_service_unavailable": {
+ "message": "Error on {target} (UPnP error code {error_code}): {error}. This may indicate the selected music service is not available on the speaker."
}
},
"issues": {
diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py
index 106ce1b87192..a05867b8fd7f 100644
--- a/homeassistant/components/spaceapi/__init__.py
+++ b/homeassistant/components/spaceapi/__init__.py
@@ -15,7 +15,6 @@ from homeassistant.const import (
ATTR_LOCATION,
ATTR_NAME,
ATTR_STATE,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_ADDRESS,
CONF_EMAIL,
CONF_ENTITY_ID,
@@ -23,6 +22,7 @@ from homeassistant.const import (
CONF_SENSORS,
CONF_STATE,
CONF_URL,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
@@ -280,8 +280,10 @@ class APISpaceApiView(HomeAssistantView):
else:
sensor_data[ATTR_LOCATION] = spaceapi[CONF_SPACE]
# Some sensors don't have a unit of measurement
- if ATTR_UNIT_OF_MEASUREMENT in sensor_state.attributes:
- sensor_data[ATTR_UNIT] = sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT]
+ if EntityStateAttribute.UNIT_OF_MEASUREMENT in sensor_state.attributes:
+ sensor_data[ATTR_UNIT] = sensor_state.attributes[
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
+ ]
return sensor_data
diff --git a/homeassistant/components/splunk/config_flow.py b/homeassistant/components/splunk/config_flow.py
index 43157661be74..437b8ad24e6e 100644
--- a/homeassistant/components/splunk/config_flow.py
+++ b/homeassistant/components/splunk/config_flow.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN
+from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SSL, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -56,7 +56,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
- vol.Optional(CONF_SSL, default=False): bool,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
vol.Optional(CONF_NAME): str,
}
@@ -109,7 +109,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
- vol.Optional(CONF_SSL, default=False): bool,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
vol.Optional(CONF_NAME): str,
}
@@ -159,7 +159,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN):
host=user_input.get(CONF_HOST, DEFAULT_HOST),
port=user_input.get(CONF_PORT, DEFAULT_PORT),
token=user_input[CONF_TOKEN],
- use_ssl=user_input.get(CONF_SSL, False),
+ use_ssl=user_input.get(CONF_SSL, DEFAULT_SSL),
verify_ssl=user_input.get(CONF_VERIFY_SSL, True),
)
diff --git a/homeassistant/components/splunk/const.py b/homeassistant/components/splunk/const.py
index 1be3beaf5019..c9cec957ca8e 100644
--- a/homeassistant/components/splunk/const.py
+++ b/homeassistant/components/splunk/const.py
@@ -6,5 +6,5 @@ CONF_FILTER = "filter"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8088
-DEFAULT_SSL = False
+DEFAULT_SSL = True
DEFAULT_NAME = "HASS"
diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json
index 99dce6f63424..c8a23a2cca3c 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.46.2"]
+ "requirements": ["async-upnp-client==0.47.0"]
}
diff --git a/homeassistant/components/starline/__init__.py b/homeassistant/components/starline/__init__.py
index 5ae3994632f0..d41bec14781a 100644
--- a/homeassistant/components/starline/__init__.py
+++ b/homeassistant/components/starline/__init__.py
@@ -1,12 +1,11 @@
"""The StarLine component."""
-import voluptuous as vol
-
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SCAN_INTERVAL
-from homeassistant.core import HomeAssistant, ServiceCall
+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 .account import StarlineAccount
from .const import (
@@ -15,13 +14,19 @@ from .const import (
DEFAULT_SCAN_OBD_INTERVAL,
DOMAIN,
PLATFORMS,
- SERVICE_SET_SCAN_INTERVAL,
- SERVICE_SET_SCAN_OBD_INTERVAL,
- SERVICE_UPDATE_STATE,
)
+from .services import async_setup_services
type StarlineConfigEntry = ConfigEntry[StarlineAccount]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the StarLine integration."""
+ async_setup_services(hass)
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: StarlineConfigEntry) -> bool:
"""Set up the StarLine device from a config entry."""
@@ -41,52 +46,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: StarlineConfigEntry) ->
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- async def async_set_scan_interval(call: ServiceCall) -> None:
- """Set scan interval."""
- options = dict(entry.options)
- options[CONF_SCAN_INTERVAL] = call.data[CONF_SCAN_INTERVAL]
- hass.config_entries.async_update_entry(entry=entry, options=options)
-
- async def async_set_scan_obd_interval(call: ServiceCall) -> None:
- """Set OBD info scan interval."""
- options = dict(entry.options)
- options[CONF_SCAN_OBD_INTERVAL] = call.data[CONF_SCAN_INTERVAL]
- hass.config_entries.async_update_entry(entry=entry, options=options)
-
- async def async_update(call: ServiceCall | None = None) -> None:
- """Update all data."""
- await account.update()
- await account.update_obd()
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, async_update)
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_SET_SCAN_INTERVAL,
- async_set_scan_interval,
- schema=vol.Schema(
- {
- vol.Required(CONF_SCAN_INTERVAL): vol.All(
- vol.Coerce(int), vol.Range(min=10)
- )
- }
- ),
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_SET_SCAN_OBD_INTERVAL,
- async_set_scan_obd_interval,
- schema=vol.Schema(
- {
- vol.Required(CONF_SCAN_INTERVAL): vol.All(
- vol.Coerce(int), vol.Range(min=180)
- )
- }
- ),
- )
-
entry.async_on_unload(entry.add_update_listener(async_options_updated))
await async_options_updated(hass, entry)
diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py
index 80fa4d9f8af2..f7cda744db7f 100644
--- a/homeassistant/components/starline/config_flow.py
+++ b/homeassistant/components/starline/config_flow.py
@@ -1,6 +1,6 @@
"""Config flow to configure StarLine component."""
-from typing import override
+from typing import TYPE_CHECKING, override
from starline import StarlineAuth
import voluptuous as vol
@@ -192,13 +192,18 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Authenticate application."""
try:
- self._app_code = await self.hass.async_add_executor_job(
- self._auth.get_app_code, self._app_id, self._app_secret
- )
- # pylint: disable-next=home-assistant-sequential-executor-jobs
- self._app_token = await self.hass.async_add_executor_job(
- self._auth.get_app_token, self._app_id, self._app_secret, self._app_code
- )
+
+ def _get_app_token() -> str:
+ if TYPE_CHECKING:
+ assert self._app_id is not None
+ assert self._app_secret is not None
+
+ app_code = self._auth.get_app_code(self._app_id, self._app_secret)
+ return self._auth.get_app_token(
+ self._app_id, self._app_secret, app_code
+ )
+
+ self._app_token = await self.hass.async_add_executor_job(_get_app_token)
return self._async_form_auth_user(error)
except Exception as err: # noqa: BLE001
_LOGGER.error("Error auth StarLine: %s", err)
diff --git a/homeassistant/components/starline/services.py b/homeassistant/components/starline/services.py
new file mode 100644
index 000000000000..2ae893b27927
--- /dev/null
+++ b/homeassistant/components/starline/services.py
@@ -0,0 +1,65 @@
+"""Services for the StarLine integration."""
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_SCAN_INTERVAL
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+
+from .const import (
+ CONF_SCAN_OBD_INTERVAL,
+ DOMAIN,
+ SERVICE_SET_SCAN_INTERVAL,
+ SERVICE_SET_SCAN_OBD_INTERVAL,
+ SERVICE_UPDATE_STATE,
+)
+
+SET_SCAN_INTERVAL_SCHEMA = vol.Schema(
+ {vol.Required(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=10))}
+)
+
+SET_SCAN_OBD_INTERVAL_SCHEMA = vol.Schema(
+ {vol.Required(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=180))}
+)
+
+
+async def _async_update(call: ServiceCall) -> None:
+ """Update all data."""
+ for entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
+ account = entry.runtime_data
+ await account.update()
+ await account.update_obd()
+
+
+async def _async_set_scan_interval(call: ServiceCall) -> None:
+ """Set scan interval."""
+ for entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
+ options = dict(entry.options)
+ options[CONF_SCAN_INTERVAL] = call.data[CONF_SCAN_INTERVAL]
+ call.hass.config_entries.async_update_entry(entry=entry, options=options)
+
+
+async def _async_set_scan_obd_interval(call: ServiceCall) -> None:
+ """Set OBD info scan interval."""
+ for entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
+ options = dict(entry.options)
+ options[CONF_SCAN_OBD_INTERVAL] = call.data[CONF_SCAN_INTERVAL]
+ call.hass.config_entries.async_update_entry(entry=entry, options=options)
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register StarLine services."""
+
+ hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, _async_update)
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_SCAN_INTERVAL,
+ _async_set_scan_interval,
+ schema=SET_SCAN_INTERVAL_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_SCAN_OBD_INTERVAL,
+ _async_set_scan_obd_interval,
+ schema=SET_SCAN_OBD_INTERVAL_SCHEMA,
+ )
diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py
index 49dcb19ceb56..4de69276a9a2 100644
--- a/homeassistant/components/statistics/__init__.py
+++ b/homeassistant/components/statistics/__init__.py
@@ -35,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py
index 688d999a3c64..23c595e16d7e 100644
--- a/homeassistant/components/statistics/sensor.py
+++ b/homeassistant/components/statistics/sensor.py
@@ -24,14 +24,13 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- ATTR_DEVICE_CLASS,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_ENTITY_ID,
CONF_NAME,
CONF_UNIQUE_ID,
PERCENTAGE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import (
CALLBACK_TYPE,
@@ -841,7 +840,9 @@ class StatisticsSensor(SensorEntity):
state characteristics.
"""
- base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ base_unit: str | None = new_state.attributes.get(
+ EntityStateAttribute.UNIT_OF_MEASUREMENT
+ )
unit: str | None = None
stat_type = self._state_characteristic
if self.is_binary and stat_type in STATS_BINARY_PERCENTAGE:
@@ -880,7 +881,7 @@ class StatisticsSensor(SensorEntity):
if stat_type in STATS_DATETIME:
return SensorDeviceClass.TIMESTAMP
if stat_type in STATS_NUMERIC_RETAIN_UNIT:
- device_class = new_state.attributes.get(ATTR_DEVICE_CLASS)
+ device_class = new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if device_class is None:
return None
if (
diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py
index 8d4464e06c25..335636df105c 100644
--- a/homeassistant/components/steam_online/__init__.py
+++ b/homeassistant/components/steam_online/__init__.py
@@ -1,9 +1,13 @@
"""The Steam integration."""
+from typing import TYPE_CHECKING
+
+from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from .const import CONF_ACCOUNTS, DOMAIN, SUBENTRY_TYPE_FRIEND
from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -16,9 +20,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> boo
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(_async_update_listener))
+
return True
+async def _async_update_listener(hass: HomeAssistant, entry: SteamConfigEntry) -> None:
+ """Handle update."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
async def async_unload_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -40,4 +51,24 @@ async def async_migrate_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> b
await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
hass.config_entries.async_update_entry(entry, version=2)
+ if entry.version < 3:
+ for steamid, name in entry.options[CONF_ACCOUNTS].items():
+ if steamid == entry.unique_id:
+ continue
+ subentry = ConfigSubentry(
+ subentry_type=SUBENTRY_TYPE_FRIEND,
+ title=name,
+ unique_id=steamid,
+ data={}, # type: ignore[arg-type]
+ )
+ hass.config_entries.async_add_subentry(entry, subentry)
+
+ dev_reg = dr.async_get(hass)
+ if device := dev_reg.async_get_device({(DOMAIN, entry.entry_id)}):
+ if TYPE_CHECKING:
+ assert entry.unique_id
+ dev_reg.async_update_device(
+ device.id, new_identifiers={(DOMAIN, entry.unique_id)}
+ )
+ hass.config_entries.async_update_entry(entry, version=3, options={})
return True
diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py
index fc5313376e80..b70f56b6b4f6 100644
--- a/homeassistant/components/steam_online/config_flow.py
+++ b/homeassistant/components/steam_online/config_flow.py
@@ -1,8 +1,9 @@
"""Config flow for Steam integration."""
-from collections.abc import Iterator, Mapping
+from collections.abc import Mapping
+from itertools import batched
import logging
-from typing import Any, override
+from typing import TYPE_CHECKING, Any, override
import steam.api
import voluptuous as vol
@@ -10,15 +11,21 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
+ ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
- OptionsFlowWithReload,
+ ConfigSubentryFlow,
+ SubentryFlowResult,
)
-from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
+from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import callback
-from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+)
-from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, PLACEHOLDERS
+from .const import CONF_ACCOUNT, DOMAIN, PLACEHOLDERS, SUBENTRY_TYPE_FRIEND
from .coordinator import SteamConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -47,16 +54,16 @@ def validate_input(user_input: dict[str, str]) -> dict[str, str | int]:
class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Steam."""
- VERSION = 2
+ VERSION = 3
- @staticmethod
+ @classmethod
@callback
@override
- def async_get_options_flow(
- config_entry: SteamConfigEntry,
- ) -> SteamOptionsFlowHandler:
- """Get the options flow for this handler."""
- return SteamOptionsFlowHandler(config_entry)
+ def async_get_supported_subentry_types(
+ cls, config_entry: SteamConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this integration."""
+ return {SUBENTRY_TYPE_FRIEND: FriendSubentryFlowHandler}
@override
async def async_step_user(
@@ -67,6 +74,14 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_ACCOUNT])
self._abort_if_unique_id_configured()
+
+ config_entries = self.hass.config_entries.async_entries(DOMAIN)
+ for entry in config_entries:
+ if user_input[CONF_ACCOUNT] in {
+ subentry.unique_id for subentry in entry.subentries.values()
+ }:
+ return self.async_abort(reason="already_configured_as_subentry")
+
try:
res = await self.hass.async_add_executor_job(validate_input, user_input)
if res is not None:
@@ -83,11 +98,7 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
if not errors:
- return self.async_create_entry(
- title=name,
- data=user_input,
- options={CONF_ACCOUNTS: {user_input[CONF_ACCOUNT]: name}},
- )
+ return self.async_create_entry(title=name, data=user_input)
user_input = user_input or {}
return self.async_show_form(
step_id="user",
@@ -138,9 +149,7 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
if not errors:
- return self.async_update_reload_and_abort(
- entry, data_updates=user_input
- )
+ return self.async_update_and_abort(entry, data_updates=user_input)
return self.async_show_form(
step_id=(
"reauth_confirm" if self.source == SOURCE_REAUTH else SOURCE_RECONFIGURE
@@ -153,77 +162,119 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
)
-def _batch_ids(ids: list[str]) -> Iterator[list[str]]:
- for i in range(0, len(ids), MAX_IDS_TO_REQUEST):
- yield ids[i : i + MAX_IDS_TO_REQUEST]
+class FriendSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow for adding a friend."""
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Subentry user flow."""
+ errors: dict[str, str] = {}
+ config_entry: SteamConfigEntry = self._get_entry()
-class SteamOptionsFlowHandler(OptionsFlowWithReload):
- """Handle Steam client options."""
+ if config_entry.state is not ConfigEntryState.LOADED:
+ return self.async_abort(reason="config_entry_not_loaded")
- def __init__(self, entry: SteamConfigEntry) -> None:
- """Initialize options flow."""
- self.options = dict(entry.options)
+ client = config_entry.runtime_data.user_interface
+ if TYPE_CHECKING:
+ assert config_entry.unique_id
- async def async_step_init(
- self, user_input: dict[str, dict[str, str]] | None = None
- ) -> ConfigFlowResult:
- """Manage Steam options."""
if user_input is not None:
- for _id in self.options[CONF_ACCOUNTS]:
- if _id not in user_input[CONF_ACCOUNTS] and (
- entity_id := er.async_get(self.hass).async_get_entity_id(
- Platform.SENSOR, DOMAIN, f"{_id}_account"
- )
- ):
- er.async_get(self.hass).async_remove(entity_id)
- channel_data = {
- CONF_ACCOUNTS: {
- _id: name
- for _id, name in self.options[CONF_ACCOUNTS].items()
- if _id in user_input[CONF_ACCOUNTS]
- }
- }
- return self.async_create_entry(title="", data=channel_data)
- error = None
+ config_entries = self.hass.config_entries.async_entries(DOMAIN)
+ if user_input[CONF_ACCOUNT] in {
+ entry.unique_id for entry in config_entries
+ }:
+ return self.async_abort(reason="already_configured_as_entry")
+ for entry in config_entries:
+ if user_input[CONF_ACCOUNT] in {
+ subentry.unique_id
+ for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND)
+ }:
+ return self.async_abort(reason="already_configured")
+
+ try:
+ title = await self.hass.async_add_executor_job(
+ lambda: client.GetPlayerSummaries(
+ steamids=[user_input[CONF_ACCOUNT]]
+ )["response"]["players"]["player"][0]["personaname"]
+ )
+ except steam.api.HTTPTimeoutError:
+ errors["base"] = "timeout_connect"
+ except steam.api.HTTPError:
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unknown exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=title,
+ data={},
+ unique_id=user_input[CONF_ACCOUNT],
+ )
+
+ def get_accounts() -> list[dict[str, Any]]:
+ friends = client.GetFriendList(steamid=config_entry.unique_id)[
+ "friendslist"
+ ]["friends"]
+ accounts = []
+ for steamids in batched(
+ [friend["steamid"] for friend in friends],
+ MAX_IDS_TO_REQUEST,
+ strict=False,
+ ):
+ accounts.extend(
+ client.GetPlayerSummaries(steamids=list(steamids))["response"][
+ "players"
+ ]["player"]
+ )
+ return accounts
+
try:
- users = {
- name["steamid"]: name["personaname"]
- for name in await self.hass.async_add_executor_job(self.get_accounts)
- }
- if not users:
- error = {"base": "unauthorized"}
-
+ accounts = await self.hass.async_add_executor_job(get_accounts)
except steam.api.HTTPTimeoutError:
- users = self.options[CONF_ACCOUNTS]
+ return self.async_abort(reason="timeout_connect")
+ except steam.api.HTTPError as e:
+ if "401" in str(e):
+ me = config_entry.runtime_data.data[config_entry.unique_id]
+ return self.async_abort(
+ reason="friendlist_private",
+ description_placeholders={
+ CONF_NAME: me.personaname,
+ "privacy_settings_url": f"{me.profileurl}edit/settings",
+ },
+ )
+ return self.async_abort(reason="cannot_connect")
+ except Exception:
+ _LOGGER.exception("Unknown exception")
+ return self.async_abort(reason="unknown")
- options = {
- vol.Required(
- CONF_ACCOUNTS,
- default=set(self.options[CONF_ACCOUNTS]),
- ): cv.multi_select(users | self.options[CONF_ACCOUNTS]),
+ existing_subentries = {
+ subentry.unique_id
+ for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND)
}
- self.options[CONF_ACCOUNTS] = users | self.options[CONF_ACCOUNTS]
+ options = [
+ SelectOptionDict(
+ value=account["steamid"],
+ label=account["personaname"],
+ )
+ for account in accounts
+ if account["steamid"] not in existing_subentries
+ ]
+
+ if not options:
+ return self.async_abort(reason="no_more_friends")
return self.async_show_form(
- step_id="init", data_schema=vol.Schema(options), errors=error
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(CONF_ACCOUNT): SelectSelector(
+ SelectSelectorConfig(options=options, sort=True)
+ )
+ }
+ ),
+ user_input,
+ ),
+ errors=errors,
)
-
- def get_accounts(self) -> list[dict[str, str | int]]:
- """Get accounts."""
- interface = steam.api.interface("ISteamUser")
- try:
- friends = interface.GetFriendList(
- steamid=self.config_entry.data[CONF_ACCOUNT]
- )
- _users_str = [user["steamid"] for user in friends["friendslist"]["friends"]]
- except steam.api.HTTPError:
- return []
- names = []
- for id_batch in _batch_ids(_users_str):
- names.extend(
- interface.GetPlayerSummaries(steamids=id_batch)["response"]["players"][
- "player"
- ]
- )
- return names
diff --git a/homeassistant/components/steam_online/const.py b/homeassistant/components/steam_online/const.py
index 14654da3b765..c5f1233d3723 100644
--- a/homeassistant/components/steam_online/const.py
+++ b/homeassistant/components/steam_online/const.py
@@ -6,7 +6,6 @@ CONF_ACCOUNT = "account"
CONF_ACCOUNTS = "accounts"
DATA_KEY_COORDINATOR = "coordinator"
-DEFAULT_NAME = "Steam"
DOMAIN: Final = "steam_online"
@@ -35,3 +34,5 @@ STEAM_API_URL = "https://steamcdn-a.akamaihd.net/steam/apps/"
STEAM_HEADER_IMAGE_FILE = "header.jpg"
STEAM_MAIN_IMAGE_FILE = "capsule_616x353.jpg"
STEAM_ICON_URL = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/"
+
+SUBENTRY_TYPE_FRIEND = "friend"
diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py
index 7c3df2b82c4a..bc929103df13 100644
--- a/homeassistant/components/steam_online/coordinator.py
+++ b/homeassistant/components/steam_online/coordinator.py
@@ -3,7 +3,7 @@
from dataclasses import dataclass
from datetime import timedelta
import logging
-from typing import override
+from typing import TYPE_CHECKING, override
import steam.api
@@ -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 CONF_ACCOUNTS, DOMAIN
+from .const import DOMAIN, SUBENTRY_TYPE_FRIEND
type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator]
@@ -45,6 +45,7 @@ class PlayerData:
loccityid: int | None = None
gameextrainfo: str | None = None
gameid: str | None = None
+ lobbysteamid: str | None = None
level: int | None = None
@@ -76,8 +77,16 @@ class SteamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PlayerData]]):
def _update(self) -> dict[str, PlayerData]:
"""Fetch data from API endpoint."""
- accounts = self.config_entry.options[CONF_ACCOUNTS]
- _ids = list(accounts)
+ if TYPE_CHECKING:
+ assert self.config_entry.unique_id
+ _ids = [self.config_entry.unique_id]
+ _ids.extend(
+ subentry.unique_id
+ for subentry in self.config_entry.get_subentries_of_type(
+ SUBENTRY_TYPE_FRIEND
+ )
+ if subentry.unique_id
+ )
response = self.user_interface.GetPlayerSummaries(steamids=_ids)
players = {
diff --git a/homeassistant/components/steam_online/entity.py b/homeassistant/components/steam_online/entity.py
index be8eebde909b..2c2732ebfa7a 100644
--- a/homeassistant/components/steam_online/entity.py
+++ b/homeassistant/components/steam_online/entity.py
@@ -4,7 +4,7 @@ from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DEFAULT_NAME, DOMAIN
+from .const import DOMAIN
from .coordinator import SteamDataUpdateCoordinator
@@ -25,9 +25,10 @@ class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]):
self.entity_description = description
self._attr_unique_id = f"{steamid}_{description.key}"
self._attr_device_info = DeviceInfo(
- configuration_url="https://store.steampowered.com",
+ configuration_url=str(coordinator.data[steamid].profileurl),
entry_type=DeviceEntryType.SERVICE,
- identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
- manufacturer=DEFAULT_NAME,
- name=DEFAULT_NAME,
+ identifiers={(DOMAIN, steamid)},
+ model="Steam",
+ manufacturer="Valve",
+ name=str(coordinator.data[steamid].personaname),
)
diff --git a/homeassistant/components/steam_online/icons.json b/homeassistant/components/steam_online/icons.json
index f2a9deb155c2..9a62815c2bbc 100644
--- a/homeassistant/components/steam_online/icons.json
+++ b/homeassistant/components/steam_online/icons.json
@@ -3,6 +3,19 @@
"sensor": {
"account": {
"default": "mdi:steam"
+ },
+ "last_online": {
+ "default": "mdi:account-clock"
+ },
+ "level": {
+ "default": "mdi:trophy-award"
+ },
+ "now_playing": {
+ "default": "mdi:controller",
+ "state": {
+ "unavailable": "mdi:controller-off",
+ "unknown": "mdi:controller-off"
+ }
}
}
}
diff --git a/homeassistant/components/steam_online/manifest.json b/homeassistant/components/steam_online/manifest.json
index 9da1f3f3c232..6c5210f8f17d 100644
--- a/homeassistant/components/steam_online/manifest.json
+++ b/homeassistant/components/steam_online/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "steam_online",
"name": "Steam",
- "codeowners": ["@tkdrob"],
+ "codeowners": ["@tr4nt0r", "@tkdrob"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/steam_online",
"integration_type": "service",
diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py
index 7749c3a5b29b..45c1cdac4de0 100644
--- a/homeassistant/components/steam_online/sensor.py
+++ b/homeassistant/components/steam_online/sensor.py
@@ -1,26 +1,31 @@
"""Sensor for Steam account status."""
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
-from typing import Any, override
+from typing import TYPE_CHECKING, Any, override
-from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
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 .const import (
- CONF_ACCOUNTS,
STEAM_API_URL,
STEAM_HEADER_IMAGE_FILE,
STEAM_ICON_URL,
STEAM_MAIN_IMAGE_FILE,
STEAM_STATUSES,
+ SUBENTRY_TYPE_FRIEND,
)
-from .coordinator import PlayerData, SteamConfigEntry, SteamDataUpdateCoordinator
+from .coordinator import PlayerData, SteamConfigEntry
from .entity import SteamEntity
PARALLEL_UPDATES = 1
@@ -30,15 +35,20 @@ class SteamSensor(StrEnum):
"""Steam sensors."""
ACCOUNT = "account"
+ LAST_ONLINE = "last_online"
+ NOW_PLAYING = "now_playing"
+ LEVEL = "level"
@dataclass(kw_only=True, frozen=True)
class SteamSensorEntityDescription(SensorEntityDescription):
"""Steam sensor description."""
- value_fn: Callable[[PlayerData], StateType]
- name_fn: Callable[[PlayerData], str]
- entity_picture_fn: Callable[[PlayerData], str] | None = None
+ value_fn: Callable[[PlayerData], StateType | datetime]
+ entity_picture_fn: Callable[[PlayerData, dict[str, str]], str | None] | None = None
+ extra_state_attributes_fn: (
+ Callable[[PlayerData, dict[str, str]], Mapping[str, Any]] | None
+ ) = None
SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = (
@@ -46,8 +56,62 @@ SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = (
key=SteamSensor.ACCOUNT,
translation_key=SteamSensor.ACCOUNT,
value_fn=lambda x: STEAM_STATUSES[x.personastate],
- name_fn=lambda x: x.personaname,
- entity_picture_fn=lambda x: x.avatarfull,
+ device_class=SensorDeviceClass.ENUM,
+ options=list(STEAM_STATUSES.values()),
+ entity_picture_fn=lambda x, _: x.avatarfull,
+ name=None,
+ # Attributes game, game_id, game_image_header, game_image_main, game_icon,
+ # last_online, and level are deprecated and can be removed in 2027.2
+ extra_state_attributes_fn=lambda x, icons: {
+ "real_name": x.realname,
+ "created": (
+ dt_util.as_local(dt_util.utc_from_timestamp(x.timecreated))
+ if x.timecreated is not None
+ else None
+ ),
+ "game": x.gameextrainfo,
+ "game_id": x.gameid,
+ "game_image_header": (
+ f"{STEAM_API_URL}{x.gameid}/{STEAM_HEADER_IMAGE_FILE}"
+ if x.gameid is not None
+ else None
+ ),
+ "game_image_main": (
+ f"{STEAM_API_URL}{x.gameid}/{STEAM_MAIN_IMAGE_FILE}"
+ if x.gameid is not None
+ else None
+ ),
+ "game_icon": (
+ f"{STEAM_ICON_URL}{x.gameid}/{info}.jpg"
+ if x.gameid is not None and (info := icons.get(x.gameid)) is not None
+ else None
+ ),
+ "last_online": dt_util.utc_from_timestamp(x.lastlogoff),
+ "level": x.level,
+ },
+ ),
+ SteamSensorEntityDescription(
+ key=SteamSensor.LAST_ONLINE,
+ translation_key=SteamSensor.LAST_ONLINE,
+ value_fn=(lambda x: dt_util.utc_from_timestamp(x.lastlogoff)),
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ SteamSensorEntityDescription(
+ key=SteamSensor.NOW_PLAYING,
+ translation_key=SteamSensor.NOW_PLAYING,
+ value_fn=lambda x: x.gameextrainfo,
+ entity_picture_fn=lambda x, icons: (
+ f"{STEAM_ICON_URL}{x.gameid}/{game_icon_url}.jpg"
+ if x.gameid and (game_icon_url := icons.get(x.gameid))
+ else None
+ ),
+ extra_state_attributes_fn=lambda x, _: {"app_id": x.gameid},
+ ),
+ SteamSensorEntityDescription(
+ key=SteamSensor.LEVEL,
+ translation_key=SteamSensor.LEVEL,
+ value_fn=lambda x: x.level,
+ state_class=SensorStateClass.MEASUREMENT,
),
)
@@ -59,33 +123,33 @@ async def async_setup_entry(
) -> None:
"""Set up the Steam platform."""
coordinator = entry.runtime_data
-
+ if TYPE_CHECKING:
+ assert entry.unique_id
async_add_entities(
- SteamSensorEntity(coordinator, steamid, description)
- for steamid in entry.options[CONF_ACCOUNTS]
+ SteamSensorEntity(coordinator, entry.unique_id, description)
for description in SENSOR_DESCRIPTIONS
- if steamid in coordinator.data
+ if entry.unique_id in coordinator.data
)
+ for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND):
+ async_add_entities(
+ [
+ SteamSensorEntity(coordinator, subentry.unique_id, description)
+ for description in SENSOR_DESCRIPTIONS
+ if subentry.unique_id in coordinator.data
+ ],
+ config_subentry_id=subentry.subentry_id,
+ )
+
class SteamSensorEntity(SteamEntity, SensorEntity):
"""Representation of a Steam sensor entity."""
entity_description: SteamSensorEntityDescription
- def __init__(
- self,
- coordinator: SteamDataUpdateCoordinator,
- steamid: str,
- description: SteamSensorEntityDescription,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, steamid, description)
- self._attr_name = self.entity_description.name_fn(coordinator.data[steamid])
-
@property
@override
- def native_value(self) -> StateType:
+ def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data[self._steamid])
@@ -94,40 +158,20 @@ class SteamSensorEntity(SteamEntity, SensorEntity):
def entity_picture(self) -> str | None:
"""Return the entity picture to use in the frontend, if any."""
return (
- fn(self.coordinator.data[self._steamid])
+ fn(self.coordinator.data[self._steamid], self.coordinator.game_icons)
if (fn := self.entity_description.entity_picture_fn) is not None
else super().entity_picture
)
@property
@override
- def extra_state_attributes(self) -> dict[str, Any]:
+ def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes of the sensor."""
- player = self.coordinator.data[self._steamid]
-
- attrs: dict[str, str | int | datetime] = {}
- if game := player.gameextrainfo:
- attrs["game"] = game
- if game_id := player.gameid:
- attrs["game_id"] = game_id
- game_url = f"{STEAM_API_URL}{player.gameid}/"
- attrs["game_image_header"] = f"{game_url}{STEAM_HEADER_IMAGE_FILE}"
- attrs["game_image_main"] = f"{game_url}{STEAM_MAIN_IMAGE_FILE}"
- if info := self._get_game_icon(player):
- attrs["game_icon"] = f"{STEAM_ICON_URL}{game_id}/{info}.jpg"
- if last_online := player.lastlogoff:
- attrs["last_online"] = dt_util.as_local(
- dt_util.utc_from_timestamp(last_online)
- )
- if level := self.coordinator.data[self._steamid].level:
- attrs["level"] = level
- return attrs
-
- def _get_game_icon(self, player: PlayerData) -> str | None:
- """Get game icon identifier."""
- if player.gameid is not None and player.gameid in self.coordinator.game_icons:
- return self.coordinator.game_icons[player.gameid]
- return None
+ return (
+ fn(self.coordinator.data[self._steamid], self.coordinator.game_icons)
+ if (fn := self.entity_description.extra_state_attributes_fn) is not None
+ else super().extra_state_attributes
+ )
@property
@override
diff --git a/homeassistant/components/steam_online/strings.json b/homeassistant/components/steam_online/strings.json
index 4d51800a505e..fdb497f21fea 100644
--- a/homeassistant/components/steam_online/strings.json
+++ b/homeassistant/components/steam_online/strings.json
@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "already_configured_as_subentry": "This Steam account is already configured as a sub-entry.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
@@ -46,6 +47,41 @@
}
}
},
+ "config_subentries": {
+ "friend": {
+ "abort": {
+ "already_configured": "Already configured as a friend in this or another account.",
+ "already_configured_as_entry": "This account is already configured as a service and cannot be added as a friend.",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "config_entry_not_loaded": "Cannot add friend accounts when the main account is disabled or not loaded.",
+ "friendlist_private": "Your friend list appears to be private or inaccessible.\n\nTo add friend accounts open Steam and go to [**{name} > Profile > Edit Profile > Privacy Settings**]({privacy_settings_url}) and set **Friends List** to **Public**.\n\nOnce your friends are added, you can switch it back to your preferred privacy setting.",
+ "no_more_friends": "All friends from your friend list have already been added.",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "entry_type": "Friend",
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "initiate_flow": {
+ "user": "Add friend"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "account": "Friend"
+ },
+ "data_description": {
+ "account": "Select a friend from your friend list to track their online status."
+ },
+ "description": "Track the online status of a Steam friend.",
+ "title": "Friend online status"
+ }
+ }
+ }
+ },
"entity": {
"sensor": {
"account": {
@@ -59,13 +95,27 @@
"snooze": "Snooze"
},
"state_attributes": {
+ "created": { "name": "Account created" },
"game": { "name": "Game" },
"game_icon": { "name": "Game icon" },
"game_id": { "name": "Game ID" },
"game_image_header": { "name": "Game header image" },
"game_image_main": { "name": "Game image" },
"last_online": { "name": "Last online" },
- "level": { "name": "Level" }
+ "level": { "name": "Level" },
+ "real_name": { "name": "Real name" }
+ }
+ },
+ "last_online": {
+ "name": "Last online"
+ },
+ "level": {
+ "name": "Level"
+ },
+ "now_playing": {
+ "name": "Now playing",
+ "state_attributes": {
+ "app_id": { "name": "Steam App ID" }
}
}
}
@@ -80,17 +130,5 @@
"timeout_exception": {
"message": "Failed to connect to Steam due to a request timeout"
}
- },
- "options": {
- "error": {
- "unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends"
- },
- "step": {
- "init": {
- "data": {
- "accounts": "Names of accounts to be monitored"
- }
- }
- }
}
}
diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py
index 92904ec5fd1f..5b96db9e2a3f 100644
--- a/homeassistant/components/steamist/__init__.py
+++ b/homeassistant/components/steamist/__init__.py
@@ -1,4 +1,5 @@
"""The Steamist integration."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from datetime import timedelta
from typing import Any
diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py
index 7802bb954b7b..41933a733e42 100644
--- a/homeassistant/components/stiebel_eltron/__init__.py
+++ b/homeassistant/components/stiebel_eltron/__init__.py
@@ -2,14 +2,15 @@
import logging
-from pymodbus.exceptions import ModbusException
+from modbus_connection import ModbusError
+from modbus_connection.pymodbus import connect_tcp
from pystiebeleltron import StiebelEltronModbusError, get_controller_model
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryNotReady
-from .const import DEFAULT_PORT
+from .const import DEFAULT_PORT, UNIT_ID
from .coordinator import StiebelEltronConfigEntry, StiebelEltronDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -25,17 +26,27 @@ async def async_setup_entry(
port = entry.data.get(CONF_PORT, DEFAULT_PORT)
try:
- model = await get_controller_model(host, port)
- except ModbusException as exception:
+ connection = await connect_tcp(host, port=port)
+ except ModbusError as exception:
raise ConfigEntryNotReady("Could not connect to device") from exception
- except StiebelEltronModbusError as exception:
- raise ConfigEntryError(exception) from exception
+ entry.async_on_unload(connection.close)
- coordinator = StiebelEltronDataCoordinator(hass, entry, model, host, port)
+ try:
+ model = await get_controller_model(connection.for_unit(UNIT_ID))
+ except StiebelEltronModbusError as exception:
+ raise ConfigEntryNotReady("Could not read controller model") from exception
+
+ coordinator = StiebelEltronDataCoordinator(hass, entry, model, connection, host)
entry.runtime_data = coordinator
await coordinator.async_config_entry_first_refresh()
+ entry.async_on_unload(
+ connection.on_connection_lost(
+ lambda: hass.config_entries.async_schedule_reload(entry.entry_id)
+ )
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
@@ -45,6 +56,4 @@ async def async_unload_entry(
entry: StiebelEltronConfigEntry,
) -> bool:
"""Unload a config entry."""
- coordinator = entry.runtime_data
- await coordinator.close()
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py
index 1f1b3ab4c8e1..1833ffc8d8b0 100644
--- a/homeassistant/components/stiebel_eltron/climate.py
+++ b/homeassistant/components/stiebel_eltron/climate.py
@@ -3,7 +3,7 @@
import logging
from typing import Any, override
-from pymodbus.exceptions import ModbusException
+from modbus_connection import ModbusError
from pystiebeleltron.lwz import OperatingMode
from homeassistant.components.climate import (
@@ -17,13 +17,15 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import StiebelEltronConfigEntry
from .coordinator import StiebelEltronDataCoordinator
+from .entity import StiebelEltronEntity
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CLIMATE_HK_1 = "climate_hk_1"
# Mapping STIEBEL ELTRON states to homeassistant states/preset.
@@ -79,13 +81,12 @@ async def async_setup_entry(
) -> None:
"""Set up STIEBEL ELTRON climate platform."""
- async_add_entities([StiebelEltron(entry.entry_id, entry.runtime_data)])
+ async_add_entities([StiebelEltron(entry.runtime_data)])
-class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEntity):
+class StiebelEltron(StiebelEltronEntity, ClimateEntity):
"""Representation of a STIEBEL ELTRON heat pump."""
- _attr_has_entity_name = True
_attr_name = None
_attr_hvac_modes = list(HA_TO_LWZ_HVAC)
_attr_preset_modes = list(HA_TO_LWZ_PRESET)
@@ -100,14 +101,12 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti
_attr_min_temp = 10.0
_attr_max_temp = 30.0
- def __init__(
- self, unique_id: str, coordinator: StiebelEltronDataCoordinator
- ) -> None:
+ def __init__(self, coordinator: StiebelEltronDataCoordinator) -> None:
"""Initialize the unit."""
- super().__init__(coordinator)
- self._attr_device_info = coordinator.device_info
- self._attr_unique_id = f"{unique_id}-{CLIMATE_HK_1}"
- # Initialize runtime attributes to avoid attribute errors
+ assert coordinator.config_entry is not None
+ super().__init__(
+ coordinator, f"{coordinator.config_entry.entry_id}-{CLIMATE_HK_1}"
+ )
self._set_attr()
@override
@@ -132,13 +131,11 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new operation mode."""
- if self.preset_mode:
- return
new_mode = HA_TO_LWZ_HVAC[hvac_mode]
_LOGGER.debug("async_set_hvac_mode: %s -> %s", self._attr_hvac_mode, new_mode)
try:
await self.coordinator.api_client.set_operation(new_mode)
- except ModbusException as e:
+ except ModbusError as e:
_LOGGER.error("Error setting HVAC mode: %s", e)
raise HomeAssistantError("Failed to set HVAC mode") from e
await self.coordinator.async_request_refresh()
@@ -146,12 +143,11 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- if (target_temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
- raise HomeAssistantError("target temperature must be provided")
+ target_temperature = kwargs[ATTR_TEMPERATURE]
_LOGGER.debug("async_set_temperature: %s", target_temperature)
try:
await self.coordinator.api_client.set_target_temp(target_temperature)
- except ModbusException as e:
+ except ModbusError as e:
_LOGGER.error("Error setting target temperature: %s", e)
raise HomeAssistantError("Failed to set target temperature") from e
await self.coordinator.async_request_refresh()
@@ -165,7 +161,7 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti
)
try:
await self.coordinator.api_client.set_operation(new_preset)
- except ModbusException as e:
+ except ModbusError as e:
_LOGGER.error("Error setting preset mode: %s", e)
raise HomeAssistantError("Failed to set preset mode") from e
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py
index ff3761e62e7f..0bbe59b0a5ee 100644
--- a/homeassistant/components/stiebel_eltron/config_flow.py
+++ b/homeassistant/components/stiebel_eltron/config_flow.py
@@ -3,22 +3,48 @@
import logging
from typing import Any, override
+from modbus_connection import ModbusError
+from modbus_connection.pymodbus import connect_tcp
from pystiebeleltron import StiebelEltronModbusError, get_controller_model
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+ TextSelector,
+)
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import DEFAULT_PORT, DOMAIN
+from .const import DEFAULT_PORT, DOMAIN, UNIT_ID
_LOGGER = logging.getLogger(__name__)
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): TextSelector(),
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All(
+ NumberSelector(
+ NumberSelectorConfig(min=1, max=65535, mode=NumberSelectorMode.BOX)
+ ),
+ vol.Coerce(int),
+ ),
+ }
+)
+
async def check_controller_model(host: str, port: int) -> str | None:
"""Check if the controller model is valid."""
try:
- await get_controller_model(host, port)
- except StiebelEltronModbusError:
+ connection = await connect_tcp(host, port=port)
+ try:
+ await get_controller_model(connection.for_unit(UNIT_ID))
+ finally:
+ await connection.close()
+ except StiebelEltronModbusError, ModbusError:
_LOGGER.debug("Cannot connect to Stiebel Eltron device", exc_info=True)
return "cannot_connect"
except Exception:
@@ -32,6 +58,41 @@ class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
+ _discovered_host: str
+
+ @override
+ async def async_step_dhcp(
+ 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})
+ self._async_abort_entries_match({CONF_HOST: discovery_info.ip})
+
+ error = await check_controller_model(discovery_info.ip, DEFAULT_PORT)
+ if error is not None:
+ return self.async_abort(reason=error)
+
+ self._discovered_host = discovery_info.ip
+ self.context["title_placeholders"] = {CONF_HOST: discovery_info.ip}
+ return await self.async_step_discovery_confirm()
+
+ async def async_step_discovery_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Allow the user to confirm adding the discovered device."""
+ if user_input is not None:
+ return self.async_create_entry(
+ title="Stiebel Eltron",
+ data={CONF_HOST: self._discovered_host, CONF_PORT: DEFAULT_PORT},
+ )
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ description_placeholders={CONF_HOST: self._discovered_host},
+ )
+
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -52,11 +113,39 @@ class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_HOST): str,
- vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
- }
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfiguration flow."""
+ config_entry = self._get_reconfigure_entry()
+
+ 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]}
+ )
+ error = await check_controller_model(
+ user_input[CONF_HOST], user_input[CONF_PORT]
+ )
+ if error is not None:
+ errors["base"] = error
+ else:
+ return self.async_update_reload_and_abort(
+ config_entry,
+ data_updates={
+ CONF_HOST: user_input[CONF_HOST],
+ CONF_PORT: user_input[CONF_PORT],
+ },
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ STEP_USER_DATA_SCHEMA, config_entry.data
),
errors=errors,
)
diff --git a/homeassistant/components/stiebel_eltron/const.py b/homeassistant/components/stiebel_eltron/const.py
index 6b6965dd244e..9522a541c31d 100644
--- a/homeassistant/components/stiebel_eltron/const.py
+++ b/homeassistant/components/stiebel_eltron/const.py
@@ -5,3 +5,4 @@ DOMAIN = "stiebel_eltron"
DEFAULT_PORT = 502
DEFAULT_SCAN_INTERVAL = 30
+UNIT_ID = 1
diff --git a/homeassistant/components/stiebel_eltron/coordinator.py b/homeassistant/components/stiebel_eltron/coordinator.py
index 4b2fda2ce817..8e1f6dde65b4 100644
--- a/homeassistant/components/stiebel_eltron/coordinator.py
+++ b/homeassistant/components/stiebel_eltron/coordinator.py
@@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from typing import override
-from pymodbus.exceptions import ModbusException
+from modbus_connection import ModbusConnection, ModbusError
from pystiebeleltron import ControllerModel
from pystiebeleltron.lwz import LwzStiebelEltronAPI
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN
+from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN, UNIT_ID
_LOGGER: logging.Logger = logging.getLogger(__package__)
@@ -28,8 +28,8 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]):
hass: HomeAssistant,
entry: StiebelEltronConfigEntry,
model: ControllerModel,
+ connection: ModbusConnection,
host: str,
- port: int,
) -> None:
"""Initialize the StiebelEltronDataCoordinator."""
super().__init__(
@@ -42,50 +42,20 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]):
# the register values), so there is nothing to diff against.
always_update=True,
)
- self._model = model
- self.api_client = LwzStiebelEltronAPI(host=host, port=port)
+ self.api_client = LwzStiebelEltronAPI(connection.for_unit(UNIT_ID))
self.device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
- configuration_url=f"http://{self.host}",
+ configuration_url=f"http://{host}",
name=self.name,
model=model.name,
model_id=str(model.value),
manufacturer=ATTR_MANUFACTURER,
)
- async def close(self) -> None:
- """Disconnect client."""
- _LOGGER.debug("Closing connection to %s", self.host)
- await self.api_client.close()
-
- async def connect(self) -> None:
- """Connect client."""
- _LOGGER.debug("Connecting to %s", self.host)
- await self.api_client.connect()
-
- @property
- def is_connected(self) -> bool:
- """Check modbus client connection status."""
- if self.api_client is None:
- return False
- return self.api_client.is_connected
-
- @property
- def host(self) -> str:
- """Return the host address of the Stiebel Eltron ISG."""
- return self.api_client.host
-
- @property
- def model(self) -> str:
- """Return the controller model name of the Stiebel Eltron ISG."""
- return self._model.name
-
@override
async def _async_update_data(self) -> None:
"""Fetch the latest data from the source."""
try:
- if not self.api_client.is_connected:
- await self.api_client.connect()
await self.api_client.async_update()
- except ModbusException as exception:
+ except ModbusError as exception:
raise UpdateFailed(exception) from exception
diff --git a/homeassistant/components/stiebel_eltron/diagnostics.py b/homeassistant/components/stiebel_eltron/diagnostics.py
new file mode 100644
index 000000000000..5267286d830c
--- /dev/null
+++ b/homeassistant/components/stiebel_eltron/diagnostics.py
@@ -0,0 +1,32 @@
+"""Diagnostics support for STIEBEL ELTRON."""
+
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+
+from . import StiebelEltronConfigEntry
+
+TO_REDACT = {CONF_HOST}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: StiebelEltronConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator = entry.runtime_data
+
+ return {
+ "entry_data": async_redact_data(entry.data, TO_REDACT),
+ "model": coordinator.device_info["model"],
+ "data": {
+ "current_temp": coordinator.api_client.get_current_temp(),
+ "target_temp": coordinator.api_client.get_target_temp(),
+ "current_humidity": coordinator.api_client.get_current_humidity(),
+ "operating_mode": coordinator.api_client.get_operation().name,
+ "heating_status": coordinator.api_client.get_heating_status(),
+ "cooling_status": coordinator.api_client.get_cooling_status(),
+ "filter_alarm": coordinator.api_client.get_filter_alarm_status(),
+ },
+ }
diff --git a/homeassistant/components/stiebel_eltron/entity.py b/homeassistant/components/stiebel_eltron/entity.py
new file mode 100644
index 000000000000..63fc2a96062d
--- /dev/null
+++ b/homeassistant/components/stiebel_eltron/entity.py
@@ -0,0 +1,19 @@
+"""Base entity for the STIEBEL ELTRON integration."""
+
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import StiebelEltronDataCoordinator
+
+
+class StiebelEltronEntity(CoordinatorEntity[StiebelEltronDataCoordinator]):
+ """Base class for STIEBEL ELTRON entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, coordinator: StiebelEltronDataCoordinator, unique_id: str
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._attr_device_info = coordinator.device_info
+ self._attr_unique_id = unique_id
diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json
index f3ff88e0e2b7..f490c4622d66 100644
--- a/homeassistant/components/stiebel_eltron/manifest.json
+++ b/homeassistant/components/stiebel_eltron/manifest.json
@@ -3,9 +3,15 @@
"name": "STIEBEL ELTRON",
"codeowners": ["@fucm", "@ThyMYthOS"],
"config_flow": true,
+ "dhcp": [
+ {
+ "hostname": "servicewelt*"
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/stiebel_eltron",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pystiebeleltron"],
- "requirements": ["pystiebeleltron==0.2.5"]
+ "quality_scale": "silver",
+ "requirements": ["pystiebeleltron==0.5.0"]
}
diff --git a/homeassistant/components/stiebel_eltron/quality_scale.yaml b/homeassistant/components/stiebel_eltron/quality_scale.yaml
new file mode 100644
index 000000000000..ca4e6a6579d3
--- /dev/null
+++ b/homeassistant/components/stiebel_eltron/quality_scale.yaml
@@ -0,0 +1,76 @@
+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-conditions:
+ status: exempt
+ comment: Integration does not register custom conditions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-triggers:
+ status: exempt
+ comment: Integration does not register custom triggers.
+ 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: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration does not have an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: Integration does not require reauthentication.
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ 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: done
+ entity-disabled-by-default: done
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: done
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: Integration does not use web sessions.
+ strict-typing: todo
diff --git a/homeassistant/components/stiebel_eltron/strings.json b/homeassistant/components/stiebel_eltron/strings.json
index aa40b754a6d6..e10233150821 100644
--- a/homeassistant/components/stiebel_eltron/strings.json
+++ b/homeassistant/components/stiebel_eltron/strings.json
@@ -3,13 +3,28 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
+ "flow_title": "STIEBEL ELTRON ({host})",
"step": {
+ "discovery_confirm": {
+ "description": "Do you want to set up the STIEBEL ELTRON heat pump at {host}?"
+ },
+ "reconfigure": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "host": "[%key:component::stiebel_eltron::config::step::user::data_description::host%]",
+ "port": "[%key:component::stiebel_eltron::config::step::user::data_description::port%]"
+ }
+ },
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py
index efbf973476bc..2279c14029b1 100644
--- a/homeassistant/components/streamlabswater/__init__.py
+++ b/homeassistant/components/streamlabswater/__init__.py
@@ -1,33 +1,29 @@
"""Support for Streamlabs Water Monitor devices."""
from streamlabswater.streamlabswater import StreamlabsClient
-import voluptuous as vol
from homeassistant.const import CONF_API_KEY, Platform
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator
-
-ATTR_AWAY_MODE = "away_mode"
-SERVICE_SET_AWAY_MODE = "set_away_mode"
-AWAY_MODE_AWAY = "away"
-AWAY_MODE_HOME = "home"
-
-CONF_LOCATION_ID = "location_id"
+from .services import async_setup_services
ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=streamlabswater"}
-SET_AWAY_MODE_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]),
- vol.Optional(CONF_LOCATION_ID): cv.string,
- }
-)
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 integration."""
+ async_setup_services(hass)
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -> bool:
"""Set up StreamLabs from a config entry."""
@@ -41,17 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- def set_away_mode(service: ServiceCall) -> None:
- """Set the StreamLabsWater Away Mode."""
- away_mode = service.data.get(ATTR_AWAY_MODE)
- location_id = service.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0]
- client.update_location(location_id, away_mode)
-
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA
- )
-
return True
diff --git a/homeassistant/components/streamlabswater/services.py b/homeassistant/components/streamlabswater/services.py
new file mode 100644
index 000000000000..f015f6365cf7
--- /dev/null
+++ b/homeassistant/components/streamlabswater/services.py
@@ -0,0 +1,44 @@
+"""Services for Streamlabs Water."""
+
+import voluptuous as vol
+
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.helpers import config_validation as cv, service
+
+from .const import DOMAIN
+from .coordinator import StreamlabsConfigEntry
+
+ATTR_AWAY_MODE = "away_mode"
+SERVICE_SET_AWAY_MODE = "set_away_mode"
+AWAY_MODE_AWAY = "away"
+AWAY_MODE_HOME = "home"
+
+CONF_LOCATION_ID = "location_id"
+
+SET_AWAY_MODE_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]),
+ vol.Optional(CONF_LOCATION_ID): cv.string,
+ }
+)
+
+
+def set_away_mode(call: ServiceCall) -> None:
+ """Set the StreamLabsWater Away Mode."""
+ entry: StreamlabsConfigEntry = service.async_get_config_entry(
+ call.hass, DOMAIN, None
+ )
+ coordinator = entry.runtime_data
+ coordinator.client.update_location(
+ call.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0],
+ call.data[ATTR_AWAY_MODE],
+ )
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register services."""
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA
+ )
diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py
index 24ea65eb465d..2e77211698ce 100644
--- a/homeassistant/components/subaru/button.py
+++ b/homeassistant/components/subaru/button.py
@@ -10,15 +10,14 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import get_device_info
from .const import (
SERVICE_REMOTE_START,
SERVICE_REMOTE_STOP,
VEHICLE_HAS_EV,
VEHICLE_HAS_REMOTE_START,
- VEHICLE_VIN,
)
from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator
+from .entity import SubaruEntity
from .remote_service import async_call_remote_service
@@ -59,10 +58,9 @@ async def async_setup_entry(
)
-class SubaruButton(ButtonEntity):
+class SubaruButton(SubaruEntity, ButtonEntity):
"""Class for a Subaru button."""
- _attr_has_entity_name = True
entity_description: SubaruButtonEntityDescription
def __init__(
@@ -73,13 +71,10 @@ class SubaruButton(ButtonEntity):
description: SubaruButtonEntityDescription,
) -> None:
"""Initialize the button for the vehicle."""
+ super().__init__(vehicle_info, description.key)
self.controller = controller
self.coordinator = coordinator
- self.vehicle_info = vehicle_info
self.entity_description = description
- vin = vehicle_info[VEHICLE_VIN]
- self._attr_unique_id = f"{vin}_{description.key}"
- self._attr_device_info = get_device_info(vehicle_info)
@override
async def async_press(self) -> None:
diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py
index 53148f9a00ad..d1a1662d6474 100644
--- a/homeassistant/components/subaru/const.py
+++ b/homeassistant/components/subaru/const.py
@@ -24,6 +24,14 @@ VEHICLE_HAS_REMOTE_SERVICE = "has_remote"
VEHICLE_HAS_SAFETY_SERVICE = "has_safety"
VEHICLE_LAST_UPDATE = "last_update"
VEHICLE_STATUS = "vehicle_status"
+VEHICLE_HEALTH = "vehicle_health"
+
+# Synthetic keys for sensors that don't read a single field directly; used
+# as both unique_id suffix and translation_key, so they must stay stable
+# across releases (changing them would orphan existing entity registry
+# entries).
+KEY_RECOMMENDED_TIRE_PRESSURE_FRONT = "recommended_tire_pressure_front"
+KEY_RECOMMENDED_TIRE_PRESSURE_REAR = "recommended_tire_pressure_rear"
API_GEN_1 = "g1"
diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py
index 9ea7929b5dcf..f31ac633893b 100644
--- a/homeassistant/components/subaru/device_tracker.py
+++ b/homeassistant/components/subaru/device_tracker.py
@@ -7,11 +7,10 @@ from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import get_device_info
-from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS, VEHICLE_VIN
+from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS
from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator
+from .entity import SubaruCoordinatorEntity
async def async_setup_entry(
@@ -29,23 +28,17 @@ async def async_setup_entry(
)
-class SubaruDeviceTracker(
- CoordinatorEntity[SubaruDataUpdateCoordinator], TrackerEntity
-):
+class SubaruDeviceTracker(SubaruCoordinatorEntity, TrackerEntity):
"""Class for Subaru device tracker."""
_attr_translation_key = "location"
- _attr_has_entity_name = True
_attr_name = None
def __init__(
self, vehicle_info: dict, coordinator: SubaruDataUpdateCoordinator
) -> None:
"""Initialize the device tracker."""
- super().__init__(coordinator)
- self.vin = vehicle_info[VEHICLE_VIN]
- self._attr_device_info = get_device_info(vehicle_info)
- self._attr_unique_id = f"{self.vin}_location"
+ super().__init__(vehicle_info, coordinator, "location")
@property
@override
@@ -72,8 +65,8 @@ class SubaruDeviceTracker(
@property
@override
def available(self) -> bool:
- """Return if entity is available."""
- if vehicle_data := self.coordinator.data.get(self.vin):
- if status := vehicle_data.get(VEHICLE_STATUS):
- return status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP}
- return False
+ """Return if available; not gated on last_update_success, only on the relevant status keys being present."""
+ if not (vehicle_data := (self.coordinator.data or {}).get(self.vin)):
+ return False
+ status = vehicle_data.get(VEHICLE_STATUS) or {}
+ return bool(status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP})
diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py
new file mode 100644
index 000000000000..a9e4ff154615
--- /dev/null
+++ b/homeassistant/components/subaru/entity.py
@@ -0,0 +1,45 @@
+"""Base entities for the Subaru integration."""
+
+from typing import Any, override
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import get_device_info
+from .const import VEHICLE_VIN
+from .coordinator import SubaruDataUpdateCoordinator
+
+
+class SubaruEntity(Entity):
+ """Base class for Subaru entities: device_info, unique_id, has_entity_name."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, vehicle_info: dict[str, Any], unique_id_suffix: str) -> None:
+ """Initialize the entity from the vehicle_info dict."""
+ self.vehicle_info = vehicle_info
+ self.vin: str = vehicle_info[VEHICLE_VIN]
+ self._attr_device_info = get_device_info(vehicle_info)
+ self._attr_unique_id = f"{self.vin}_{unique_id_suffix}"
+
+
+class SubaruCoordinatorEntity(
+ CoordinatorEntity[SubaruDataUpdateCoordinator], SubaruEntity
+):
+ """Base class for coordinator-backed Subaru entities."""
+
+ def __init__(
+ self,
+ vehicle_info: dict[str, Any],
+ coordinator: SubaruDataUpdateCoordinator,
+ unique_id_suffix: str,
+ ) -> None:
+ """Initialize the coordinator-backed entity."""
+ super().__init__(coordinator)
+ SubaruEntity.__init__(self, vehicle_info, unique_id_suffix)
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return if available; also gates on data for this vehicle being present."""
+ return super().available and self.vin in self.coordinator.data
diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py
index 62547ee51e5b..362e3ebe4c3d 100644
--- a/homeassistant/components/subaru/lock.py
+++ b/homeassistant/components/subaru/lock.py
@@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import get_device_info
from .const import (
ATTR_DOOR,
SERVICE_UNLOCK_SPECIFIC_DOOR,
@@ -19,9 +18,9 @@ from .const import (
UNLOCK_VALID_DOORS,
VEHICLE_HAS_REMOTE_SERVICE,
VEHICLE_NAME,
- VEHICLE_VIN,
)
from .coordinator import SubaruConfigEntry
+from .entity import SubaruEntity
from .remote_service import async_call_remote_service
_LOGGER = logging.getLogger(__name__)
@@ -50,7 +49,7 @@ async def async_setup_entry(
)
-class SubaruLock(LockEntity):
+class SubaruLock(SubaruEntity, LockEntity):
"""Representation of a Subaru door lock.
Note that the Subaru API currently does not support
@@ -58,17 +57,13 @@ class SubaruLock(LockEntity):
always unknown.
"""
- _attr_has_entity_name = True
_attr_translation_key = "door_locks"
def __init__(self, vehicle_info, controller):
"""Initialize the locks for the vehicle."""
+ super().__init__(vehicle_info, "door_locks")
self.controller = controller
- self.vehicle_info = vehicle_info
- vin = vehicle_info[VEHICLE_VIN]
self.car_name = vehicle_info[VEHICLE_NAME]
- self._attr_unique_id = f"{vin}_door_locks"
- self._attr_device_info = get_device_info(vehicle_info)
@override
async def async_lock(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py
index ccf99fc46500..4fff8efb10ba 100644
--- a/homeassistant/components/subaru/sensor.py
+++ b/homeassistant/components/subaru/sensor.py
@@ -1,5 +1,9 @@
"""Support for Subaru sensors."""
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import date, datetime
+from decimal import Decimal
import logging
from typing import Any, override
@@ -12,25 +16,33 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfPressure, UnitOfVolume
+from homeassistant.const import (
+ PERCENTAGE,
+ EntityCategory,
+ UnitOfLength,
+ UnitOfPressure,
+ UnitOfVolume,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.helpers.typing import StateType
from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter
from homeassistant.util.unit_system import METRIC_SYSTEM
-from . import get_device_info
from .const import (
API_GEN_2,
API_GEN_3,
API_GEN_4,
+ KEY_RECOMMENDED_TIRE_PRESSURE_FRONT,
+ KEY_RECOMMENDED_TIRE_PRESSURE_REAR,
VEHICLE_API_GEN,
VEHICLE_HAS_EV,
+ VEHICLE_HEALTH,
VEHICLE_STATUS,
- VEHICLE_VIN,
)
from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator
+from .entity import SubaruCoordinatorEntity
_LOGGER = logging.getLogger(__name__)
@@ -42,9 +54,56 @@ FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal"
L_PER_GAL = VolumeConverter.convert(1, UnitOfVolume.GALLONS, UnitOfVolume.LITERS)
KM_PER_MI = DistanceConverter.convert(1, UnitOfLength.MILES, UnitOfLength.KILOMETERS)
+# Readable aliases for subarulink field-key constants.
+API_KEY_VEHICLE_STATE_TYPE = sc.VEHICLE_STATE
+API_KEY_RECOMMENDED_TIRE_PRESSURE = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE
+API_KEY_FRONT_TIRES = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE_FRONT
+API_KEY_REAR_TIRES = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE_REAR
+
+
+@dataclass(frozen=True, kw_only=True)
+class SubaruSensorEntityDescription(SensorEntityDescription):
+ """Describes a Subaru sensor entity."""
+
+ value_fn: Callable[[dict[str, Any]], StateType] | None = None
+
+
+def _recommended_tire_pressure(axle: str) -> Callable[[dict[str, Any]], StateType]:
+ """Return a getter for recommended FRONT or REAR axle tire pressure from vehicle_health."""
+
+ def getter(data: dict[str, Any]) -> StateType:
+ health = data.get(VEHICLE_HEALTH) or {}
+ recommended = health.get(API_KEY_RECOMMENDED_TIRE_PRESSURE) or {}
+ return recommended.get(axle)
+
+ return getter
+
+
+# Snake-case ENUM options for vehicle_state. Authoritative values confirmed by
+# the integration codeowner against the Subaru Android app source (see PR
+# #174054 discussion_r3488335137 and subarulink PR G-Two/subarulink#121).
+# IGN-ACC, IGN-ON, and ENGINE_ON_REMOTE_START are not yet exported as
+# `sc.*` constants in the released subarulink; the literal strings will be
+# replaced once the next subarulink pin lands.
+VEHICLE_STATE_OPTIONS = {
+ sc.IGNITION_OFF: "ignition_off",
+ "IGN-ACC": "ignition_acc",
+ "IGN-ON": "ignition_on",
+ "ENGINE_ON_REMOTE_START": "engine_on_remote_start",
+}
+
+
+def _vehicle_state_enum(data: dict[str, Any]) -> StateType:
+ """Map the raw VEHICLE_STATE_TYPE to a snake_case ENUM option (unmapped → None → `unknown`)."""
+ raw = (data.get(VEHICLE_STATUS) or {}).get(API_KEY_VEHICLE_STATE_TYPE)
+ if raw is None:
+ return None
+ return VEHICLE_STATE_OPTIONS.get(raw)
+
+
# Sensor available for Gen1 or Gen2 vehicles
SAFETY_SENSORS = [
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.ODOMETER,
translation_key="odometer",
device_class=SensorDeviceClass.DISTANCE,
@@ -55,52 +114,78 @@ SAFETY_SENSORS = [
# Sensors available to subscribers with Gen2/Gen3 vehicles
API_GEN_2_SENSORS = [
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.AVG_FUEL_CONSUMPTION,
translation_key="average_fuel_consumption",
native_unit_of_measurement=FUEL_CONSUMPTION_MILES_PER_GALLON,
state_class=SensorStateClass.MEASUREMENT,
),
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.DIST_TO_EMPTY,
translation_key="range",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILES,
state_class=SensorStateClass.MEASUREMENT,
),
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.TIRE_PRESSURE_FL,
translation_key="tire_pressure_front_left",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.PSI,
state_class=SensorStateClass.MEASUREMENT,
),
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.TIRE_PRESSURE_FR,
translation_key="tire_pressure_front_right",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.PSI,
state_class=SensorStateClass.MEASUREMENT,
),
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.TIRE_PRESSURE_RL,
translation_key="tire_pressure_rear_left",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.PSI,
state_class=SensorStateClass.MEASUREMENT,
),
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.TIRE_PRESSURE_RR,
translation_key="tire_pressure_rear_right",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.PSI,
state_class=SensorStateClass.MEASUREMENT,
),
+ SubaruSensorEntityDescription(
+ key=API_KEY_VEHICLE_STATE_TYPE,
+ translation_key="vehicle_state",
+ device_class=SensorDeviceClass.ENUM,
+ options=sorted(VEHICLE_STATE_OPTIONS.values()),
+ value_fn=_vehicle_state_enum,
+ ),
+ # Static manufacturer reference value, not a live measurement; no state_class.
+ SubaruSensorEntityDescription(
+ key=KEY_RECOMMENDED_TIRE_PRESSURE_FRONT,
+ translation_key=KEY_RECOMMENDED_TIRE_PRESSURE_FRONT,
+ device_class=SensorDeviceClass.PRESSURE,
+ native_unit_of_measurement=UnitOfPressure.PSI,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=_recommended_tire_pressure(API_KEY_FRONT_TIRES),
+ ),
+ SubaruSensorEntityDescription(
+ key=KEY_RECOMMENDED_TIRE_PRESSURE_REAR,
+ translation_key=KEY_RECOMMENDED_TIRE_PRESSURE_REAR,
+ device_class=SensorDeviceClass.PRESSURE,
+ native_unit_of_measurement=UnitOfPressure.PSI,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=_recommended_tire_pressure(API_KEY_REAR_TIRES),
+ ),
]
# Sensors available for Gen3 vehicles
API_GEN_3_SENSORS = [
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.REMAINING_FUEL_PERCENT,
translation_key="fuel_level",
native_unit_of_measurement=PERCENTAGE,
@@ -110,21 +195,21 @@ API_GEN_3_SENSORS = [
# Sensors available to subscribers with PHEV vehicles
EV_SENSORS = [
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.EV_DISTANCE_TO_EMPTY,
translation_key="ev_range",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILES,
state_class=SensorStateClass.MEASUREMENT,
),
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.EV_STATE_OF_CHARGE_PERCENT,
translation_key="ev_battery_level",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
- SensorEntityDescription(
+ SubaruSensorEntityDescription(
key=sc.EV_TIME_TO_FULLY_CHARGED_UTC,
translation_key="ev_time_to_full_charge",
device_class=SensorDeviceClass.TIMESTAMP,
@@ -173,34 +258,37 @@ def create_vehicle_sensors(
]
-class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity):
+class SubaruSensor(SubaruCoordinatorEntity, SensorEntity):
"""Class for Subaru sensors."""
- _attr_has_entity_name = True
+ entity_description: SubaruSensorEntityDescription
def __init__(
self,
vehicle_info: dict,
coordinator: SubaruDataUpdateCoordinator,
- description: SensorEntityDescription,
+ description: SubaruSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
- super().__init__(coordinator)
- self.vin = vehicle_info[VEHICLE_VIN]
+ super().__init__(vehicle_info, coordinator, description.key)
self.entity_description = description
- self._attr_device_info = get_device_info(vehicle_info)
- self._attr_unique_id = f"{self.vin}_{description.key}"
@property
@override
- def native_value(self) -> int | float | None:
+ def native_value(self) -> StateType | date | datetime | Decimal:
"""Return the state of the sensor."""
- current_value = self.coordinator.data[self.vin][VEHICLE_STATUS].get(
- self.entity_description.key
- )
+ vehicle_data = self.coordinator.data[self.vin]
+ if self.entity_description.value_fn is not None:
+ current_value = self.entity_description.value_fn(vehicle_data)
+ else:
+ current_value = vehicle_data[VEHICLE_STATUS].get(
+ self.entity_description.key
+ )
if (
self.entity_description.key == sc.AVG_FUEL_CONSUMPTION
+ and isinstance(current_value, (int, float))
+ and current_value > 0
and self.hass.config.units == METRIC_SYSTEM
):
return round((100.0 * L_PER_GAL) / (KM_PER_MI * current_value), 1)
@@ -218,15 +306,6 @@ class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity)
return FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS
return self.entity_description.native_unit_of_measurement
- @property
- @override
- def available(self) -> bool:
- """Return if entity is available."""
- last_update_success = super().available
- if last_update_success and self.vin not in self.coordinator.data:
- return False
- return last_update_success
-
async def _async_migrate_entries(
hass: HomeAssistant, config_entry: ConfigEntry
diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json
index 5e72848e46b3..fd8209f150ac 100644
--- a/homeassistant/components/subaru/strings.json
+++ b/homeassistant/components/subaru/strings.json
@@ -82,6 +82,12 @@
"range": {
"name": "Range"
},
+ "recommended_tire_pressure_front": {
+ "name": "Recommended tire pressure front"
+ },
+ "recommended_tire_pressure_rear": {
+ "name": "Recommended tire pressure rear"
+ },
"tire_pressure_front_left": {
"name": "Tire pressure front left"
},
@@ -93,6 +99,15 @@
},
"tire_pressure_rear_right": {
"name": "Tire pressure rear right"
+ },
+ "vehicle_state": {
+ "name": "Vehicle state",
+ "state": {
+ "engine_on_remote_start": "Engine on (remote start)",
+ "ignition_acc": "Accessory power",
+ "ignition_off": "Ignition off",
+ "ignition_on": "Ignition on"
+ }
}
}
},
diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py
index 262457c4620e..d7e67ba34fbf 100644
--- a/homeassistant/components/suez_water/sensor.py
+++ b/homeassistant/components/suez_water/sensor.py
@@ -10,6 +10,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
+ SensorStateClass,
)
from homeassistant.const import CURRENCY_EURO, UnitOfVolume
from homeassistant.core import HomeAssistant
@@ -41,8 +42,8 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = (
SuezWaterSensorEntityDescription(
key="water_price",
translation_key="water_price",
- native_unit_of_measurement=CURRENCY_EURO,
- device_class=SensorDeviceClass.MONETARY,
+ native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}",
+ state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda suez_data: suez_data.price,
),
)
diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py
index 6a13d3c5d1ef..c4012e10f588 100644
--- a/homeassistant/components/sunricher_dali/__init__.py
+++ b/homeassistant/components/sunricher_dali/__init__.py
@@ -59,10 +59,7 @@ def _remove_missing_devices(
continue
if domain_device_ids.isdisjoint(known_device_ids):
- device_registry.async_update_device(
- device_entry.id,
- remove_config_entry_id=entry.entry_id,
- )
+ device_registry.async_remove_device(device_entry.id)
async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py
index 01d21e1be58e..e1d7f163843a 100644
--- a/homeassistant/components/surepetcare/__init__.py
+++ b/homeassistant/components/surepetcare/__init__.py
@@ -3,30 +3,31 @@
from datetime import timedelta
import logging
-from surepy.enums import Location
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
-import voluptuous as vol
-from homeassistant.const import ATTR_LOCATION, Platform
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
-from .const import (
- ATTR_FLAP_ID,
- ATTR_LOCK_STATE,
- ATTR_PET_NAME,
- DOMAIN,
- SERVICE_SET_LOCK_STATE,
- SERVICE_SET_PET_LOCATION,
-)
+from .const import DOMAIN
from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator
+from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR]
SCAN_INTERVAL = timedelta(minutes=3)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Sure Petcare services."""
+ async_setup_services(hass)
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: SurePetcareConfigEntry) -> bool:
"""Set up Sure Petcare from a config entry."""
@@ -43,45 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SurePetcareConfigEntry)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- lock_state_service_schema = vol.Schema(
- {
- vol.Required(ATTR_FLAP_ID): vol.All(
- cv.positive_int, vol.In(coordinator.data.keys())
- ),
- vol.Required(ATTR_LOCK_STATE): vol.All(
- cv.string,
- vol.Lower,
- vol.In(coordinator.lock_states_callbacks.keys()),
- ),
- }
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_SET_LOCK_STATE,
- coordinator.handle_set_lock_state,
- schema=lock_state_service_schema,
- )
-
- set_pet_location_schema = vol.Schema(
- {
- vol.Required(ATTR_PET_NAME): vol.In(coordinator.get_pets().keys()),
- vol.Required(ATTR_LOCATION): vol.In(
- [
- Location.INSIDE.name.title(),
- Location.OUTSIDE.name.title(),
- ]
- ),
- }
- )
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN,
- SERVICE_SET_PET_LOCATION,
- coordinator.handle_set_pet_location,
- schema=set_pet_location_schema,
- )
-
return True
diff --git a/homeassistant/components/surepetcare/services.py b/homeassistant/components/surepetcare/services.py
new file mode 100644
index 000000000000..6ff3d3c41c3a
--- /dev/null
+++ b/homeassistant/components/surepetcare/services.py
@@ -0,0 +1,91 @@
+"""Support for Sure Petcare services."""
+
+from surepy.enums import Location
+import voluptuous as vol
+
+from homeassistant.const import ATTR_LOCATION
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import config_validation as cv, service
+
+from .const import (
+ ATTR_FLAP_ID,
+ ATTR_LOCK_STATE,
+ ATTR_PET_NAME,
+ DOMAIN,
+ SERVICE_SET_LOCK_STATE,
+ SERVICE_SET_PET_LOCATION,
+)
+from .coordinator import SurePetcareConfigEntry
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register Sure Petcare services."""
+
+ async def handle_set_lock_state(call: ServiceCall) -> None:
+ """Set lock state for a flap."""
+ entry: SurePetcareConfigEntry = service.async_get_config_entry(
+ hass, DOMAIN, None
+ )
+ coordinator = entry.runtime_data
+ if call.data[ATTR_FLAP_ID] not in coordinator.data:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_flap_id",
+ translation_placeholders={"flap_id": call.data[ATTR_FLAP_ID]},
+ )
+ await coordinator.handle_set_lock_state(call)
+
+ async def handle_set_pet_location(call: ServiceCall) -> None:
+ """Set pet location."""
+ entry: SurePetcareConfigEntry = service.async_get_config_entry(
+ hass, DOMAIN, None
+ )
+ coordinator = entry.runtime_data
+ if call.data[ATTR_PET_NAME] not in coordinator.get_pets():
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_pet_name",
+ translation_placeholders={"pet_name": call.data[ATTR_PET_NAME]},
+ )
+ await coordinator.handle_set_pet_location(call)
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_LOCK_STATE,
+ handle_set_lock_state,
+ schema=vol.Schema(
+ {
+ vol.Required(ATTR_FLAP_ID): cv.positive_int,
+ vol.Required(ATTR_LOCK_STATE): vol.All(
+ cv.string,
+ vol.Lower,
+ vol.In(
+ [
+ "unlocked",
+ "locked_in",
+ "locked_out",
+ "locked_all",
+ ]
+ ),
+ ),
+ }
+ ),
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_PET_LOCATION,
+ handle_set_pet_location,
+ schema=vol.Schema(
+ {
+ vol.Required(ATTR_PET_NAME): cv.string,
+ vol.Required(ATTR_LOCATION): vol.In(
+ [
+ Location.INSIDE.name.title(),
+ Location.OUTSIDE.name.title(),
+ ]
+ ),
+ }
+ ),
+ )
diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json
index 890c97ba3e57..ecb60ced5486 100644
--- a/homeassistant/components/surepetcare/strings.json
+++ b/homeassistant/components/surepetcare/strings.json
@@ -25,6 +25,14 @@
}
}
},
+ "exceptions": {
+ "invalid_flap_id": {
+ "message": "Unknown Sure Petcare flap ID: {flap_id}"
+ },
+ "invalid_pet_name": {
+ "message": "Unknown Sure Petcare pet: {pet_name}"
+ }
+ },
"services": {
"set_lock_state": {
"description": "Sets lock state.",
diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py
index fe1e92ab6f26..c17a591e64c1 100644
--- a/homeassistant/components/swiss_public_transport/__init__.py
+++ b/homeassistant/components/swiss_public_transport/__init__.py
@@ -128,9 +128,7 @@ async def async_migrate_entry(
device_registry, config_entry_id=config_entry.entry_id
)
for dev in device_entries:
- device_registry.async_update_device(
- dev.id, remove_config_entry_id=config_entry.entry_id
- )
+ device_registry.async_remove_device(dev.id)
entity_id = entity_registry.async_get_entity_id(
Platform.SENSOR, DOMAIN, "None_departure"
diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json
index 8b259e82d90d..6beb51f2b7fe 100644
--- a/homeassistant/components/swisscom/manifest.json
+++ b/homeassistant/components/swisscom/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/swisscom",
"integration_type": "hub",
"iot_class": "local_polling",
- "requirements": ["python-swisscom-internet-box==0.1.1"]
+ "requirements": ["python-swisscom-internet-box==0.2.0"]
}
diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py
index ef0a5cc5e3a0..e44aa0da3b1d 100644
--- a/homeassistant/components/switch_as_x/__init__.py
+++ b/homeassistant/components/switch_as_x/__init__.py
@@ -60,7 +60,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_get_parent_device_id(hass, entity_id),
diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py
index b97cfda1ea93..3223f24fa027 100644
--- a/homeassistant/components/switchbot/__init__.py
+++ b/homeassistant/components/switchbot/__init__.py
@@ -427,9 +427,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry)
minor_version = entry.minor_version
_LOGGER.debug("Migrating from version %s.%s", version, minor_version)
- if version > 1:
- return False
-
if version == 1 and minor_version < 2:
new_options: dict[str, Any] = {**entry.options}
diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py
index 683f98ec4b27..7487e64d312f 100644
--- a/homeassistant/components/switchbot/cover.py
+++ b/homeassistant/components/switchbot/cover.py
@@ -6,13 +6,12 @@ from typing import Any, override
import switchbot
from homeassistant.components.cover import (
- ATTR_CURRENT_POSITION,
- ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION,
ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
+ CoverEntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -77,11 +76,14 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
"""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:
+ if (
+ not last_state
+ or CoverEntityStateAttribute.CURRENT_POSITION not in last_state.attributes
+ ):
return
self._attr_current_cover_position = last_state.attributes.get(
- ATTR_CURRENT_POSITION
+ CoverEntityStateAttribute.CURRENT_POSITION
)
self._last_run_success = last_state.attributes.get("last_run_success")
if self._attr_current_cover_position is not None:
@@ -172,11 +174,15 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
"""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_TILT_POSITION not in last_state.attributes:
+ if (
+ not last_state
+ or CoverEntityStateAttribute.CURRENT_TILT_POSITION
+ not in last_state.attributes
+ ):
return
self._attr_current_cover_tilt_position = last_state.attributes.get(
- ATTR_CURRENT_TILT_POSITION
+ CoverEntityStateAttribute.CURRENT_TILT_POSITION
)
self._last_run_success = last_state.attributes.get("last_run_success")
if (_tilt := self._attr_current_cover_tilt_position) is not None:
@@ -260,11 +266,14 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
"""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:
+ if (
+ not last_state
+ or CoverEntityStateAttribute.CURRENT_POSITION not in last_state.attributes
+ ):
return
self._attr_current_cover_position = last_state.attributes.get(
- ATTR_CURRENT_POSITION
+ CoverEntityStateAttribute.CURRENT_POSITION
)
self._last_run_success = last_state.attributes.get("last_run_success")
if self._attr_current_cover_position is not None:
diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py
index 4ee1116a78cb..421a867da3be 100644
--- a/homeassistant/components/switchbot_cloud/__init__.py
+++ b/homeassistant/components/switchbot_cloud/__init__.py
@@ -186,24 +186,6 @@ async def make_device_data(
)
devices_data.vacuums.append((device, coordinator))
- if isinstance(device, Device) and device.device_type in [
- "Smart Lock",
- "Smart Lock Lite",
- "Smart Lock Pro",
- "Smart Lock Ultra",
- "Smart Lock Vision",
- "Smart Lock Vision Pro",
- "Smart Lock Pro Wifi",
- "Lock Vision",
- "Lock Vision Pro",
- ]:
- coordinator = await coordinator_for_device(
- hass, entry, api, device, coordinators_by_id
- )
- devices_data.locks.append((device, coordinator))
- devices_data.sensors.append((device, coordinator))
- devices_data.binary_sensors.append((device, coordinator))
-
if isinstance(device, Device) and device.device_type == "Bot":
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id, True
@@ -297,13 +279,6 @@ async def make_device_data(
)
devices_data.humidifiers.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
- if isinstance(device, Device) and device.device_type == "AI Art Frame":
- coordinator = await coordinator_for_device(
- hass, entry, api, device, coordinators_by_id
- )
- devices_data.buttons.append((device, coordinator))
- devices_data.sensors.append((device, coordinator))
- devices_data.images.append((device, coordinator))
await make_new_device_data(
hass, entry, api, device, devices_data, coordinators_by_id
diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py
index 0b5bc94ef8b7..971dab2d94eb 100644
--- a/homeassistant/components/switchbot_cloud/climate.py
+++ b/homeassistant/components/switchbot_cloud/climate.py
@@ -15,7 +15,6 @@ from switchbot_api import (
from homeassistant.components import climate as FanState
from homeassistant.components.climate import (
- ATTR_FAN_MODE,
ATTR_TEMPERATURE,
PRESET_BOOST,
PRESET_COMFORT,
@@ -24,6 +23,7 @@ from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
+ ClimateEntityStateAttribute,
HVACMode,
)
from homeassistant.const import (
@@ -128,10 +128,10 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity, RestoreE
_LOGGER.debug("Last state attributes: %s", last_state.attributes)
self._attr_hvac_mode = HVACMode(last_state.state)
self._attr_fan_mode = last_state.attributes.get(
- ATTR_FAN_MODE, self._attr_fan_mode
+ ClimateEntityStateAttribute.FAN_MODE, self._attr_fan_mode
)
self._attr_target_temperature = last_state.attributes.get(
- ATTR_TEMPERATURE, self._attr_target_temperature
+ ClimateEntityStateAttribute.TEMPERATURE, self._attr_target_temperature
)
def _get_mode(self, hvac_mode: HVACMode | None) -> int:
diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py
index 835e5ce1077b..d496470a26df 100644
--- a/homeassistant/components/switchbot_cloud/const.py
+++ b/homeassistant/components/switchbot_cloud/const.py
@@ -124,5 +124,35 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = {
"WoIOSensor": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
+ "Smart Lock": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR, Platform.LOCK)
+ ),
+ "Smart Lock Ultra": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK)
+ ),
+ "Smart Lock Vision": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK)
+ ),
+ "Smart Lock Vision Pro": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK)
+ ),
+ "Lock Vision": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK)
+ ),
+ "Lock Vision Pro": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK)
+ ),
+ "Smart Lock Lite": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK)
+ ),
+ "Smart Lock Pro": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK)
+ ),
+ "Smart Lock Pro Wifi": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK)
+ ),
"MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
+ "AI Art Frame": SwitchbotCloudDeviceConfig(
+ True, entity_config=(Platform.SENSOR, Platform.BUTTON, Platform.IMAGE)
+ ),
}
diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py
index bb06518111b7..c17ac1128b45 100644
--- a/homeassistant/components/switchbot_cloud/lock.py
+++ b/homeassistant/components/switchbot_cloud/lock.py
@@ -44,8 +44,14 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity):
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if coord_data := self.coordinator.data:
- self._attr_is_locked = coord_data["lockState"] == "locked"
- if self.__model != "Smart Lock Lite":
+ self._attr_is_locked = coord_data["lockState"].lower() == "locked"
+ if self.__model not in [
+ "Smart Lock Lite",
+ "Smart Lock Vision",
+ "Smart Lock Vision Pro",
+ "Lock Vision",
+ "Lock Vision Pro",
+ ]:
self._attr_supported_features = LockEntityFeature.OPEN
@override
diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py
index 892b1f1bd021..7c3ff8f5584b 100644
--- a/homeassistant/components/switchbot_cloud/vacuum.py
+++ b/homeassistant/components/switchbot_cloud/vacuum.py
@@ -75,8 +75,7 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity):
# "Robot Vacuum Cleaner S1 Plus"
_attr_supported_features: VacuumEntityFeature = (
- VacuumEntityFeature.BATTERY
- | VacuumEntityFeature.FAN_SPEED
+ VacuumEntityFeature.FAN_SPEED
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.START
@@ -123,7 +122,6 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity):
if self.coordinator.data is None:
return
- self._attr_battery_level = self.coordinator.data.get("battery")
self._attr_available = self.coordinator.data.get("onlineStatus") == "online"
switchbot_state = str(self.coordinator.data.get("workingStatus"))
diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json
index fd3fb5a6f181..00d599145150 100644
--- a/homeassistant/components/synology_dsm/manifest.json
+++ b/homeassistant/components/synology_dsm/manifest.json
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["synology_dsm"],
- "requirements": ["py-synologydsm-api==2.10.2"],
+ "requirements": ["py-synologydsm-api==2.10.3"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:Basic:1",
diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py
index ddde26ae083f..6d22cb777f22 100644
--- a/homeassistant/components/tado/config_flow.py
+++ b/homeassistant/components/tado/config_flow.py
@@ -18,7 +18,6 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.core import callback
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
@@ -223,9 +222,9 @@ class OptionsFlowHandler(OptionsFlow):
return self.async_show_form(step_id="init", data_schema=data_schema)
-class CannotConnect(HomeAssistantError):
+class CannotConnect(Exception):
"""Error to indicate we cannot connect."""
-class TadoRateLimitExceeded(HomeAssistantError):
+class TadoRateLimitExceeded(Exception):
"""Error to indicate Tado API rate limit exceeded."""
diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py
index 46387ef34f61..4574f2bc0b45 100644
--- a/homeassistant/components/tankerkoenig/__init__.py
+++ b/homeassistant/components/tankerkoenig/__init__.py
@@ -3,7 +3,7 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
+from .const import DEFAULT_SCAN_INTERVAL
from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -13,8 +13,6 @@ async def async_setup_entry(
hass: HomeAssistant, entry: TankerkoenigConfigEntry
) -> bool:
"""Set a tankerkoenig configuration entry up."""
- hass.data.setdefault(DOMAIN, {})
-
coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL)
await coordinator.async_setup()
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py
index 344778e7ba42..336c3e4377d7 100644
--- a/homeassistant/components/tankerkoenig/binary_sensor.py
+++ b/homeassistant/components/tankerkoenig/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -42,7 +42,7 @@ async def async_setup_entry(
class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorEntity):
"""Shows if a station is open or closed."""
- _attr_device_class = BinarySensorDeviceClass.DOOR
+ _attr_device_class = BinarySensorDeviceClass.OPENING
_attr_translation_key = "status"
def __init__(
@@ -56,8 +56,8 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE
self._attr_unique_id = f"{station.id}_status"
if coordinator.show_on_map:
self._attr_extra_state_attributes = {
- ATTR_LATITUDE: station.lat,
- ATTR_LONGITUDE: station.lng,
+ EntityStateAttribute.LATITUDE: station.lat,
+ EntityStateAttribute.LONGITUDE: station.lng,
}
@property
diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py
index c8dd1b396dad..1e47c5344a60 100644
--- a/homeassistant/components/tankerkoenig/coordinator.py
+++ b/homeassistant/components/tankerkoenig/coordinator.py
@@ -108,9 +108,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf
for station_id in self._selected_stations
):
_LOGGER.debug("Removing obsolete device entry %s", device.name)
- device_reg.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_reg.async_remove_device(device.id)
if len(self.stations) > 10:
_LOGGER.warning(
diff --git a/homeassistant/components/tankerkoenig/icons.json b/homeassistant/components/tankerkoenig/icons.json
index 05b9d3bcbca6..47f03fd7bca5 100644
--- a/homeassistant/components/tankerkoenig/icons.json
+++ b/homeassistant/components/tankerkoenig/icons.json
@@ -1,5 +1,14 @@
{
"entity": {
+ "binary_sensor": {
+ "status": {
+ "default": "mdi:store",
+ "state": {
+ "off": "mdi:store-off",
+ "on": "mdi:store"
+ }
+ }
+ },
"sensor": {
"diesel": {
"default": "mdi:gas-station"
diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py
index e551432b5f4a..06483fec5714 100644
--- a/homeassistant/components/tankerkoenig/sensor.py
+++ b/homeassistant/components/tankerkoenig/sensor.py
@@ -6,7 +6,7 @@ from typing import override
from aiotankerkoenig import GasType, Station
from homeassistant.components.sensor import SensorEntity, SensorStateClass
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO
+from homeassistant.const import CURRENCY_EURO, EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -76,8 +76,8 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity):
ATTR_STATION_NAME,
ATTR_STREET,
ATTRIBUTION,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
+ EntityStateAttribute.LATITUDE,
+ EntityStateAttribute.LONGITUDE,
}
)
@@ -104,8 +104,8 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity):
}
if coordinator.show_on_map:
- attrs[ATTR_LATITUDE] = station.lat
- attrs[ATTR_LONGITUDE] = station.lng
+ attrs[EntityStateAttribute.LATITUDE] = station.lat
+ attrs[EntityStateAttribute.LONGITUDE] = station.lng
self._attr_extra_state_attributes = attrs
@property
diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py
index 18c979b2f373..844aed33075a 100644
--- a/homeassistant/components/telegram_bot/__init__.py
+++ b/homeassistant/components/telegram_bot/__init__.py
@@ -3,6 +3,7 @@
import logging
from typing import Protocol, cast
+import telegram
from telegram import Bot
from telegram.constants import InputMediaType
from telegram.error import InvalidToken, TelegramError
@@ -33,6 +34,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import (
config_validation as cv,
+ device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
@@ -104,6 +106,7 @@ from .const import (
CHAT_ACTION_UPLOAD_VIDEO_NOTE,
CHAT_ACTION_UPLOAD_VOICE,
CONF_API_ENDPOINT,
+ CONF_CHAT_ID,
CONF_CONFIG_ENTRY_ID,
DEFAULT_API_ENDPOINT,
DOMAIN,
@@ -705,6 +708,46 @@ async def async_migrate_entry(
updated,
)
+ # version 1.2 -> 1.3: give each chat its own device, linked to the shared bot device,
+ # and make sure the bot device is tied to (entry, None).
+ if version == 1 and config_entry.minor_version < 3:
+ device_registry = dr.async_get(hass)
+ entity_registry = er.async_get(hass)
+ devices = dr.async_entries_for_config_entry(
+ device_registry, config_entry.entry_id
+ )
+ if devices:
+ bot_device = devices[0]
+ bot_id = next(
+ identifier
+ for domain, identifier in bot_device.identifiers
+ if domain == DOMAIN
+ )
+ notify_entities = {
+ entity.config_subentry_id: entity
+ for entity in er.async_entries_for_config_entry(
+ entity_registry, config_entry.entry_id
+ )
+ # The event entity (no subentry) stays on the shared bot device
+ if entity.config_subentry_id is not None
+ }
+ for subentry_id, subentry in config_entry.subentries.items():
+ per_chat_device = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ config_subentry_id=subentry_id,
+ identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")},
+ via_device_id=bot_device.id,
+ )
+ if entity := notify_entities.get(subentry_id):
+ entity_registry.async_update_entity(
+ entity.entity_id, device_id=per_chat_device.id
+ )
+ # Hand the bot device back to (entry, None), keeping the event entity
+ device_registry.async_update_device(
+ bot_device.id, new_config_subentry_id=None
+ )
+ hass.config_entries.async_update_entry(config_entry, minor_version=3)
+
return True
@@ -906,6 +949,18 @@ def _warn_chat_id_migration(service: ServiceCall) -> set[int]:
return chat_ids
+def bot_device_info(config_entry: TelegramBotConfigEntry, bot_id: int) -> dr.DeviceInfo:
+ """Return device info for the shared bot device."""
+ return dr.DeviceInfo(
+ name=config_entry.title,
+ entry_type=dr.DeviceEntryType.SERVICE,
+ manufacturer="Telegram",
+ model=config_entry.data[CONF_PLATFORM].capitalize(),
+ sw_version=telegram.__version__,
+ identifiers={(DOMAIN, f"{bot_id}")},
+ )
+
+
async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool:
"""Create the Telegram bot from config entry."""
bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data)
@@ -933,6 +988,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry)
)
entry.runtime_data = notify_service
+ # Create the bot device before the platforms are set up, so the per-chat devices can
+ # resolve it as their via_device no matter which platform is set up first
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id, **bot_device_info(entry, bot.id)
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py
index 6d5422b8368b..0aa84bc996c9 100644
--- a/homeassistant/components/telegram_bot/config_flow.py
+++ b/homeassistant/components/telegram_bot/config_flow.py
@@ -192,7 +192,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Telegram."""
VERSION = 1
- MINOR_VERSION = 2
+ MINOR_VERSION = 3
@staticmethod
@callback
diff --git a/homeassistant/components/telegram_bot/entity.py b/homeassistant/components/telegram_bot/entity.py
index 95adc934781a..1b71426a89fe 100644
--- a/homeassistant/components/telegram_bot/entity.py
+++ b/homeassistant/components/telegram_bot/entity.py
@@ -1,13 +1,8 @@
"""Base entity for Telegram bot integration."""
-import telegram
-
-from homeassistant.const import CONF_PLATFORM
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
-from . import TelegramBotConfigEntry
-from .const import DOMAIN
+from . import TelegramBotConfigEntry, bot_device_info
class TelegramBotEntity(Entity):
@@ -28,11 +23,4 @@ class TelegramBotEntity(Entity):
self.service = config_entry.runtime_data
self._attr_unique_id = f"{self.bot_id}_{entity_description.key}"
- self._attr_device_info = DeviceInfo(
- name=config_entry.title,
- entry_type=DeviceEntryType.SERVICE,
- manufacturer="Telegram",
- model=config_entry.data[CONF_PLATFORM].capitalize(),
- sw_version=telegram.__version__,
- identifiers={(DOMAIN, f"{self.bot_id}")},
- )
+ self._attr_device_info = bot_device_info(config_entry, self.bot_id)
diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py
index c49d106a84d2..e95cf2de681e 100644
--- a/homeassistant/components/telegram_bot/notify.py
+++ b/homeassistant/components/telegram_bot/notify.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TelegramBotConfigEntry
-from .const import ATTR_TITLE, CONF_CHAT_ID
+from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN
from .entity import TelegramBotEntity
@@ -33,6 +33,7 @@ async def async_setup_entry(
class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity):
"""Representation of a telegram bot notification entity."""
+ _attr_name = None
_attr_supported_features = NotifyEntityFeature.TITLE
def __init__(
@@ -45,7 +46,12 @@ class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity):
config_entry, NotifyEntityDescription(key=subentry.data[CONF_CHAT_ID])
)
self.chat_id = subentry.data[CONF_CHAT_ID]
- self._attr_name = subentry.title
+ # Each chat gets its own device (keyed per chat) linked to the shared bot device.
+ device_info = self._attr_device_info
+ assert device_info is not None
+ device_info["identifiers"] = {(DOMAIN, f"{self.bot_id}_{self.chat_id}")}
+ device_info["name"] = subentry.title
+ device_info["via_device"] = (DOMAIN, f"{self.bot_id}")
@override
async def async_send_message(self, message: str, title: str | None = None) -> None:
diff --git a/homeassistant/components/teltonika/quality_scale.yaml b/homeassistant/components/teltonika/quality_scale.yaml
index 96e783ea3585..eb563705413f 100644
--- a/homeassistant/components/teltonika/quality_scale.yaml
+++ b/homeassistant/components/teltonika/quality_scale.yaml
@@ -59,7 +59,9 @@ rules:
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: todo
- dynamic-devices: todo
+ dynamic-devices:
+ status: exempt
+ comment: Integration only has static hardware devices, single device per config entry
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
@@ -68,7 +70,9 @@ rules:
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
- stale-devices: todo
+ stale-devices:
+ status: exempt
+ comment: Integration only has static hardware devices, single device per config entry
# Platinum
async-dependency: done
diff --git a/homeassistant/components/temperature/condition.py b/homeassistant/components/temperature/condition.py
index 9b407851eb64..a793108de64d 100644
--- a/homeassistant/components/temperature/condition.py
+++ b/homeassistant/components/temperature/condition.py
@@ -3,20 +3,19 @@
from typing import override
from homeassistant.components.climate import (
- ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE,
DOMAIN as CLIMATE_DOMAIN,
+ ClimateEntityStateAttribute,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.water_heater import (
- ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
DOMAIN as WATER_HEATER_DOMAIN,
+ WaterHeaterStateAttribute,
)
from homeassistant.components.weather import (
- ATTR_WEATHER_TEMPERATURE,
- ATTR_WEATHER_TEMPERATURE_UNIT,
DOMAIN as WEATHER_DOMAIN,
+ WeatherEntityStateAttribute,
)
-from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature
+from homeassistant.const import EntityStateAttribute, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
@@ -27,16 +26,16 @@ from homeassistant.util.unit_conversion import TemperatureConverter
TEMPERATURE_DOMAIN_SPECS: dict[str, DomainSpec] = {
CLIMATE_DOMAIN: DomainSpec(
- value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE,
+ value_source=ClimateEntityStateAttribute.CURRENT_TEMPERATURE,
),
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.TEMPERATURE,
),
WATER_HEATER_DOMAIN: DomainSpec(
- value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
+ value_source=WaterHeaterStateAttribute.CURRENT_TEMPERATURE,
),
WEATHER_DOMAIN: DomainSpec(
- value_source=ATTR_WEATHER_TEMPERATURE,
+ value_source=WeatherEntityStateAttribute.TEMPERATURE,
),
}
@@ -68,9 +67,11 @@ class TemperatureCondition(EntityNumericalConditionWithUnitBase):
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if entity_state.domain == SENSOR_DOMAIN:
- return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ return entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if entity_state.domain == WEATHER_DOMAIN:
- return entity_state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT)
+ return entity_state.attributes.get(
+ WeatherEntityStateAttribute.TEMPERATURE_UNIT
+ )
# Climate and water_heater: show_temp converts to system unit
return self._hass.config.units.temperature_unit
diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py
index a1022422b207..dd7d6de43845 100644
--- a/homeassistant/components/temperature/trigger.py
+++ b/homeassistant/components/temperature/trigger.py
@@ -3,20 +3,19 @@
from typing import override
from homeassistant.components.climate import (
- ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE,
DOMAIN as CLIMATE_DOMAIN,
+ ClimateEntityStateAttribute,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.water_heater import (
- ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
DOMAIN as WATER_HEATER_DOMAIN,
+ WaterHeaterStateAttribute,
)
from homeassistant.components.weather import (
- ATTR_WEATHER_TEMPERATURE,
- ATTR_WEATHER_TEMPERATURE_UNIT,
DOMAIN as WEATHER_DOMAIN,
+ WeatherEntityStateAttribute,
)
-from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature
+from homeassistant.const import EntityStateAttribute, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
@@ -29,14 +28,16 @@ from homeassistant.util.unit_conversion import TemperatureConverter
TEMPERATURE_DOMAIN_SPECS: dict[str, DomainSpec] = {
CLIMATE_DOMAIN: DomainSpec(
- value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE,
+ value_source=ClimateEntityStateAttribute.CURRENT_TEMPERATURE,
),
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.TEMPERATURE,
),
- WATER_HEATER_DOMAIN: DomainSpec(value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE),
+ WATER_HEATER_DOMAIN: DomainSpec(
+ value_source=WaterHeaterStateAttribute.CURRENT_TEMPERATURE
+ ),
WEATHER_DOMAIN: DomainSpec(
- value_source=ATTR_WEATHER_TEMPERATURE,
+ value_source=WeatherEntityStateAttribute.TEMPERATURE,
),
}
@@ -70,9 +71,9 @@ class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if state.domain == SENSOR_DOMAIN:
- return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ return state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if state.domain == WEATHER_DOMAIN:
- return state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT)
+ return state.attributes.get(WeatherEntityStateAttribute.TEMPERATURE_UNIT)
# Climate and water_heater: show_temp converts to system unit
return self._hass.config.units.temperature_unit
diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py
index 1ba5fa21e824..b825552e8170 100644
--- a/homeassistant/components/template/__init__.py
+++ b/homeassistant/components/template/__init__.py
@@ -29,7 +29,14 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
from homeassistant.util.hass_dict import HassKey
-from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN, PLATFORMS
+from .const import (
+ CONF_ADDITIONAL_OPTIONS,
+ CONF_MAX,
+ CONF_MIN,
+ CONF_STEP,
+ DOMAIN,
+ PLATFORMS,
+)
from .coordinator import TriggerUpdateCoordinator
from .helpers import async_get_blueprints
@@ -141,6 +148,14 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry, version=1, minor_version=2
)
+ options = {**config_entry.options}
+ # The "advanced_options" section was renamed to "additional_options"
+ if (additional := options.pop("advanced_options", None)) is not None:
+ options[CONF_ADDITIONAL_OPTIONS] = additional
+ hass.config_entries.async_update_entry(
+ config_entry, options=options, version=2, minor_version=1
+ )
+
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
config_entry.version,
diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py
index 0d28bc3c6aa9..934cd6a3f6bf 100644
--- a/homeassistant/components/template/config_flow.py
+++ b/homeassistant/components/template/config_flow.py
@@ -60,7 +60,7 @@ from .alarm_control_panel import (
)
from .binary_sensor import async_create_preview_binary_sensor
from .const import (
- CONF_ADVANCED_OPTIONS,
+ CONF_ADDITIONAL_OPTIONS,
CONF_AVAILABILITY,
CONF_PRESS,
CONF_TURN_OFF,
@@ -157,7 +157,7 @@ _SCHEMA_STATE: dict[vol.Marker, Any] = {
def generate_schema(domain: str, flow_type: str) -> vol.Schema:
"""Generate schema."""
schema: dict[vol.Marker, Any] = {}
- advanced_options: dict[vol.Marker, Any] = {}
+ additional_options: dict[vol.Marker, Any] = {}
if flow_type == "config":
schema = {vol.Required(CONF_NAME): selector.TextSelector()}
@@ -240,7 +240,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
vol.Optional(CONF_LATITUDE): selector.TemplateSelector(),
vol.Optional(CONF_LONGITUDE): selector.TemplateSelector(),
}
- advanced_options |= {
+ additional_options |= {
vol.Optional(CONF_LOCATION_ACCURACY): selector.TemplateSelector(),
}
@@ -445,11 +445,11 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
schema |= {
vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
- vol.Optional(CONF_ADVANCED_OPTIONS): section(
+ vol.Optional(CONF_ADDITIONAL_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(),
- **advanced_options,
+ **additional_options,
}
),
{"collapsed": True},
@@ -782,8 +782,7 @@ class TemplateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
options_flow = OPTIONS_FLOW
options_flow_reloads = True
- MINOR_VERSION = 2
- VERSION = 1
+ VERSION = 2
@callback
@override
@@ -901,9 +900,9 @@ def ws_start_preview(
return
config: dict = msg["user_input"]
- advanced_options = config.pop(CONF_ADVANCED_OPTIONS, {})
+ additional_options = config.pop(CONF_ADDITIONAL_OPTIONS, {})
preview_entity = CREATE_PREVIEW_ENTITY[template_type](
- hass, name, {**config, **advanced_options}
+ hass, name, {**config, **additional_options}
)
preview_entity.hass = hass
preview_entity.registry_entry = entity_registry_entry
diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py
index cbb9c3beb272..816b77b5284d 100644
--- a/homeassistant/components/template/const.py
+++ b/homeassistant/components/template/const.py
@@ -3,7 +3,7 @@
from homeassistant.const import Platform
from homeassistant.helpers.typing import ConfigType
-CONF_ADVANCED_OPTIONS = "advanced_options"
+CONF_ADDITIONAL_OPTIONS = "additional_options"
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
CONF_ATTRIBUTES = "attributes"
CONF_AVAILABILITY = "availability"
diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py
index 040887e67132..a61b9cb5e60e 100644
--- a/homeassistant/components/template/cover.py
+++ b/homeassistant/components/template/cover.py
@@ -1,6 +1,7 @@
"""Support for covers which integrate with other components."""
-from typing import TYPE_CHECKING, Any, override
+from dataclasses import asdict, dataclass
+from typing import TYPE_CHECKING, Any, Self, override
import voluptuous as vol
@@ -22,6 +23,7 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
+from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator, validators as template_validators
@@ -158,13 +160,40 @@ def async_create_preview_cover(
)
-class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
+@dataclass(kw_only=True)
+class CoverExtraStoredData(ExtraStoredData):
+ """Holds extra stored data for template cover entities."""
+
+ current_cover_position: int | None
+ current_cover_tilt_position: int | None
+ is_opening: bool | None
+ is_closing: bool | None
+
+ @override
+ def as_dict(self) -> dict[str, Any]:
+ """Return a dict representation of the cover data."""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, restored: dict[str, Any]) -> Self:
+ """Initialize a stored cover state from a dict."""
+ return cls(
+ current_cover_position=restored["current_cover_position"],
+ current_cover_tilt_position=restored["current_cover_tilt_position"],
+ is_opening=restored["is_opening"],
+ is_closing=restored["is_closing"],
+ )
+
+
+class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity, RestoreEntity):
"""Representation of a template cover features."""
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_extra_optimistic_options = (CONF_POSITION,)
_state_option = CONF_STATE
+ _restore_state_extra_data = CoverExtraStoredData
+ _restore_state_properties = ("_attr_current_cover_position",)
# The super init is not called because TemplateEntity
# and TriggerEntity will call
@@ -324,6 +353,25 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
if self._tilt_optimistic:
self.async_write_ha_state()
+ @property
+ @override
+ def extra_restore_state_data(self) -> CoverExtraStoredData:
+ """Return cover specific state data to be restored."""
+ return CoverExtraStoredData(
+ current_cover_position=self._attr_current_cover_position,
+ current_cover_tilt_position=self._attr_current_cover_tilt_position,
+ is_opening=self._attr_is_opening,
+ is_closing=self._attr_is_closing,
+ )
+
+ @override
+ def restore_extra_data(self, extra_data: CoverExtraStoredData) -> None:
+ """Restore the extra data."""
+ self._attr_current_cover_position = extra_data.current_cover_position
+ self._attr_current_cover_tilt_position = extra_data.current_cover_tilt_position
+ self._attr_is_opening = extra_data.is_opening
+ self._attr_is_closing = extra_data.is_closing
+
class StateCoverEntity(TemplateEntity, AbstractTemplateCover):
"""Representation of a Template cover."""
diff --git a/homeassistant/components/template/device_tracker.py b/homeassistant/components/template/device_tracker.py
index 3c7f013ddbc7..e0103fdf92ca 100644
--- a/homeassistant/components/template/device_tracker.py
+++ b/homeassistant/components/template/device_tracker.py
@@ -1,7 +1,8 @@
"""Support for device trackers which integrates with other components."""
from collections.abc import Callable
-from typing import Any
+from dataclasses import asdict, dataclass
+from typing import Any, Self, override
import voluptuous as vol
@@ -19,6 +20,7 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
+from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator, validators as template_validators
@@ -174,10 +176,37 @@ def async_create_preview_tracker(
)
-class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity):
+@dataclass(kw_only=True)
+class TrackerExtraStoredData(ExtraStoredData):
+ """Holds extra stored data for template tracker entities."""
+
+ in_zones: list[str] | None
+ latitude: float | None
+ longitude: float | None
+ location_accuracy: float
+
+ @override
+ def as_dict(self) -> dict[str, Any]:
+ """Return a dict representation of the tracker data."""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, restored: dict[str, Any]) -> Self:
+ """Initialize a stored tracker state from a dict."""
+ return cls(
+ in_zones=restored["in_zones"],
+ latitude=restored["latitude"],
+ longitude=restored["longitude"],
+ location_accuracy=restored["location_accuracy"],
+ )
+
+
+class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity, RestoreEntity):
"""Representation of a template device tracker features."""
_entity_id_format = ENTITY_ID_FORMAT
+ _restore_state_extra_data = TrackerExtraStoredData
+ _restore_state_properties = ("_attr_in_zones",)
# The super init is not called because TemplateEntity
# and TriggerEntity will call
@@ -217,6 +246,25 @@ class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity):
"""Update the location accuracy."""
self._attr_location_accuracy = self._location_accuracy_validator(value) or 0.0
+ @property
+ @override
+ def extra_restore_state_data(self) -> TrackerExtraStoredData:
+ """Return tracker specific state data to be restored."""
+ return TrackerExtraStoredData(
+ in_zones=self._attr_in_zones,
+ latitude=self._attr_latitude,
+ longitude=self._attr_longitude,
+ location_accuracy=self._attr_location_accuracy,
+ )
+
+ @override
+ def restore_extra_data(self, extra_data: TrackerExtraStoredData) -> None:
+ """Restore the extra data."""
+ self._attr_in_zones = extra_data.in_zones
+ self._attr_latitude = extra_data.latitude
+ self._attr_longitude = extra_data.longitude
+ self._attr_location_accuracy = extra_data.location_accuracy
+
class StateTrackerEntity(TemplateEntity, AbstractTemplateTracker):
"""Representation of a Template device tracker."""
diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py
index 755a928af372..edcbc7056820 100644
--- a/homeassistant/components/template/entity.py
+++ b/homeassistant/components/template/entity.py
@@ -6,15 +6,13 @@ from dataclasses import dataclass
from typing import Any, override
from homeassistant.const import (
- ATTR_ENTITY_PICTURE,
- ATTR_FRIENDLY_NAME,
- ATTR_ICON,
CONF_DEVICE_ID,
CONF_ICON,
CONF_NAME,
CONF_OPTIMISTIC,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import Context, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr
@@ -289,9 +287,9 @@ class AbstractTemplateEntity(Entity):
"""Restore attributes from the last state."""
# Restore built-in attributes from templates
for conf_key, attr, _attr in (
- (CONF_ICON, ATTR_ICON, "_attr_icon"),
- (CONF_NAME, ATTR_FRIENDLY_NAME, "_attr_name"),
- (CONF_PICTURE, ATTR_ENTITY_PICTURE, "_attr_entity_picture"),
+ (CONF_ICON, EntityStateAttribute.ICON, "_attr_icon"),
+ (CONF_NAME, EntityStateAttribute.FRIENDLY_NAME, "_attr_name"),
+ (CONF_PICTURE, EntityStateAttribute.ENTITY_PICTURE, "_attr_entity_picture"),
):
if conf_key not in self._config or attr not in last_state.attributes:
continue
diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py
index d2c7b3b68150..5a8f221ae98a 100644
--- a/homeassistant/components/template/fan.py
+++ b/homeassistant/components/template/fan.py
@@ -1,8 +1,9 @@
"""Support for Template fans."""
+from dataclasses import asdict, dataclass
from enum import StrEnum
import logging
-from typing import TYPE_CHECKING, Any, override
+from typing import TYPE_CHECKING, Any, Self, override
import voluptuous as vol
@@ -22,6 +23,7 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
+from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import validators as template_validators
@@ -155,12 +157,56 @@ def async_create_preview_fan(
)
-class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
+@dataclass(kw_only=True)
+class FanExtraStoredData(ExtraStoredData):
+ """Fan extra stored data."""
+
+ is_on: bool | None
+ percentage: int | None
+ preset_mode: str | None
+ oscillating: bool | None
+ direction: str | None
+
+ @override
+ def as_dict(self) -> dict[str, Any]:
+ """Return a dict representation of the fan data."""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, restored: dict[str, Any]) -> Self | None:
+ """Initialize a stored fan data from a dict."""
+ is_on = restored.get("is_on")
+ percentage = restored.get("percentage")
+ preset_mode = restored.get("preset_mode")
+ oscillating = restored.get("oscillating")
+ direction = restored.get("direction")
+ if is_on is not None and not isinstance(is_on, bool):
+ return None
+ if percentage is not None and not isinstance(percentage, int):
+ return None
+ if preset_mode is not None and not isinstance(preset_mode, str):
+ return None
+ if oscillating is not None and not isinstance(oscillating, bool):
+ return None
+ if direction is not None and not isinstance(direction, str):
+ return None
+ return cls(
+ is_on=is_on,
+ percentage=percentage,
+ preset_mode=preset_mode,
+ oscillating=oscillating,
+ direction=direction,
+ )
+
+
+class AbstractTemplateFan(AbstractTemplateEntity, FanEntity, RestoreEntity):
"""Representation of a template fan features."""
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
+ _restore_state_extra_data = FanExtraStoredData
+ _restore_state_properties = ("_attr_is_on",)
# The super init is not called because TemplateEntity
# and TriggerEntity will call
@@ -344,6 +390,27 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
", ".join(_VALID_DIRECTIONS),
)
+ @property
+ @override
+ def extra_restore_state_data(self) -> FanExtraStoredData:
+ """Return extra state data to be restored."""
+ return FanExtraStoredData(
+ is_on=self._attr_is_on,
+ percentage=self._attr_percentage,
+ preset_mode=self._attr_preset_mode,
+ oscillating=self._attr_oscillating,
+ direction=self._attr_current_direction,
+ )
+
+ @override
+ def restore_extra_data(self, extra_data: FanExtraStoredData) -> None:
+ """Restore extra state data."""
+ self._attr_is_on = extra_data.is_on
+ self._attr_percentage = extra_data.percentage
+ self._attr_preset_mode = extra_data.preset_mode
+ self._attr_oscillating = extra_data.oscillating
+ self._attr_current_direction = extra_data.direction
+
class StateFanEntity(TemplateEntity, AbstractTemplateFan):
"""A template fan component."""
diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py
index 959fbcb0bc37..66ca4eec45b5 100644
--- a/homeassistant/components/template/helpers.py
+++ b/homeassistant/components/template/helpers.py
@@ -31,7 +31,7 @@ from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
-from .const import CONF_ADVANCED_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN
+from .const import CONF_ADDITIONAL_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN
from .entity import AbstractTemplateEntity
from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
@@ -240,8 +240,8 @@ async def async_setup_template_entry(
options = dict(config_entry.options)
options.pop("template_type")
- if advanced_options := options.pop(CONF_ADVANCED_OPTIONS, None):
- options = {**options, **advanced_options}
+ if additional_options := options.pop(CONF_ADDITIONAL_OPTIONS, None):
+ options = {**options, **additional_options}
if replace_value_template and CONF_VALUE_TEMPLATE in options:
options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE)
diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json
index 5e550532e189..8c9028e03b9f 100644
--- a/homeassistant/components/template/strings.json
+++ b/homeassistant/components/template/strings.json
@@ -1,6 +1,6 @@
{
"common": {
- "advanced_options": "Advanced options",
+ "additional_options": "Additional options",
"availability": "Availability template",
"availability_description": "Defines a template to get the `available` state of the entity. If the template either fails to render or returns `True`, `\"1\"`, `\"true\"`, `\"yes\"`, `\"on\"`, `\"enable\"`, or a non-zero number, the entity will be `available`. If the template returns any other value, the entity will be `unavailable`. If not configured, the entity will always be `available`. Note that the string comparison is not case sensitive; `\"TrUe\"` and `\"yEs\"` are allowed.",
"code_format": "Code format",
@@ -42,14 +42,14 @@
"value_template": "Defines a template to set the state of the alarm panel. Valid output values from the template are `armed_away`, `armed_home`, `armed_night`, `armed_vacation`, `arming`, `disarmed`, `pending`, and `triggered`."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template alarm control panel"
@@ -66,14 +66,14 @@
"state": "The sensor is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template binary sensor"
@@ -90,14 +90,14 @@
"press": "Defines actions to run when button is pressed."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template button"
@@ -124,14 +124,14 @@
"stop_cover": "Defines actions to run when the cover is stopped."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template cover"
@@ -152,7 +152,7 @@
"name": "[%key:common::config_flow::data::name%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]",
"location_accuracy": "Location accuracy"
@@ -161,7 +161,7 @@
"availability": "[%key:component::template::common::availability_description%]",
"location_accuracy": "Defines a template to get the accuracy of the device tracker's location in meters. Valid values are numbers greater than or equal to `0`."
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template device tracker"
@@ -180,14 +180,14 @@
"event_types": "Defines a template for a list of available event types."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template event"
@@ -213,14 +213,14 @@
"turn_on": "Defines actions to run when the fan is turned on. Receives variables `percentage` and/or `preset_mode`."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template fan"
@@ -238,14 +238,14 @@
"verify_ssl": "Enable or disable SSL certificate verification. Disable to use an http URL, or if you have a self-signed SSL certificate and haven’t installed the CA certificate to enable verification."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template image"
@@ -277,14 +277,14 @@
"turn_on": "Defines actions to run when the light is turned on."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template light"
@@ -308,14 +308,14 @@
"unlock": "Defines actions to run when the lock is unlocked."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template lock"
@@ -342,14 +342,14 @@
"unit_of_measurement": "Defines the unit of measurement of the number, if any."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template number"
@@ -369,14 +369,14 @@
"state": "Template for the select’s current value."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template select"
@@ -396,14 +396,14 @@
"unit_of_measurement": "Defines the unit of measurement for the sensor, if any. This will also display the value based on the number format setting in the user profile and influence the graphical presentation in the history visualization as a continuous value."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template sensor"
@@ -423,14 +423,14 @@
"value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template switch"
@@ -465,14 +465,14 @@
"update_percentage": "Defines a template to get the update completion percentage."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template update"
@@ -529,14 +529,14 @@
"stop": "Defines actions to run when the vacuum is stopped."
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template vacuum"
@@ -562,11 +562,11 @@
"temperature_unit": "The temperature unit"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template weather"
@@ -589,10 +589,6 @@
"config_format_triggers": {
"description": "A trigger template configuration needs a trigger and at least one domain when defining an entity. This will be an configuration validation error in Home Assistant Core 2026.5.\n\n Please remove the orphaned trigger from the configuration.\n\n```\n{config}\n```",
"title": "Incomplete template configuration"
- },
- "deprecated_battery_level": {
- "description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name}).",
- "title": "Deprecated battery level option in {entity_name}"
}
},
"options": {
@@ -625,14 +621,14 @@
"value_template": "[%key:component::template::config::step::alarm_control_panel::data_description::value_template%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::alarm_control_panel::title%]"
@@ -648,14 +644,14 @@
"state": "[%key:component::template::config::step::binary_sensor::data_description::state%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::binary_sensor::title%]"
@@ -670,14 +666,14 @@
"press": "[%key:component::template::config::step::button::data_description::press%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::button::title%]"
@@ -702,14 +698,14 @@
"stop_cover": "[%key:component::template::config::step::cover::data_description::stop_cover%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::cover::title%]"
@@ -728,16 +724,16 @@
"longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]",
- "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data::location_accuracy%]"
+ "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::additional_options::data::location_accuracy%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]",
- "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data_description::location_accuracy%]"
+ "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::additional_options::data_description::location_accuracy%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::device_tracker::title%]"
@@ -755,14 +751,14 @@
"event_types": "[%key:component::template::config::step::event::data_description::event_types%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::event::title%]"
@@ -787,14 +783,14 @@
"turn_on": "[%key:component::template::config::step::fan::data_description::turn_on%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::fan::title%]"
@@ -811,14 +807,14 @@
"verify_ssl": "[%key:component::template::config::step::image::data_description::verify_ssl%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::image::title%]"
@@ -850,14 +846,14 @@
"turn_on": "[%key:component::template::config::step::light::data_description::turn_on%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::light::title%]"
@@ -880,14 +876,14 @@
"unlock": "[%key:component::template::config::step::lock::data_description::unlock%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::lock::title%]"
@@ -912,14 +908,14 @@
"step": "[%key:component::template::config::step::number::data_description::step%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::number::title%]"
@@ -939,14 +935,14 @@
"state": "[%key:component::template::config::step::select::data_description::state%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::select::title%]"
@@ -965,14 +961,14 @@
"unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::unit_of_measurement%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::sensor::title%]"
@@ -992,14 +988,14 @@
"value_template": "[%key:component::template::config::step::switch::data_description::value_template%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::switch::title%]"
@@ -1034,14 +1030,14 @@
"update_percentage": "[%key:component::template::config::step::update::data_description::update_percentage%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "Template update"
@@ -1075,14 +1071,14 @@
"stop": "[%key:component::template::config::step::vacuum::data_description::stop%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::vacuum::title%]"
@@ -1108,11 +1104,11 @@
"temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]"
},
"sections": {
- "advanced_options": {
+ "additional_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
- "name": "[%key:component::template::common::advanced_options%]"
+ "name": "[%key:component::template::common::additional_options%]"
}
},
"title": "[%key:component::template::config::step::weather::title%]"
diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py
index c0c33b5a0406..dcd7ff22284e 100644
--- a/homeassistant/components/template/update.py
+++ b/homeassistant/components/template/update.py
@@ -6,13 +6,12 @@ from typing import TYPE_CHECKING, Any, override
import voluptuous as vol
from homeassistant.components.update import (
- ATTR_INSTALLED_VERSION,
- ATTR_LATEST_VERSION,
DEVICE_CLASSES_SCHEMA,
DOMAIN as UPDATE_DOMAIN,
ENTITY_ID_FORMAT,
UpdateEntity,
UpdateEntityFeature,
+ UpdateEntityStateAttribute,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
@@ -244,8 +243,12 @@ class AbstractTemplateUpdate(AbstractTemplateEntity, UpdateEntity):
@override
def restore_last_state_state(self, last_state: State) -> bool:
"""Restore the state from the last state."""
- self._attr_installed_version = last_state.attributes[ATTR_INSTALLED_VERSION]
- self._attr_latest_version = last_state.attributes[ATTR_LATEST_VERSION]
+ self._attr_installed_version = last_state.attributes[
+ UpdateEntityStateAttribute.INSTALLED_VERSION
+ ]
+ self._attr_latest_version = last_state.attributes[
+ UpdateEntityStateAttribute.LATEST_VERSION
+ ]
return True
diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py
index 6a8cf8a5bfd6..f0412fb90373 100644
--- a/homeassistant/components/template/vacuum.py
+++ b/homeassistant/components/template/vacuum.py
@@ -23,12 +23,11 @@ from homeassistant.components.vacuum import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv, issue_registry as ir
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
-from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator, validators as template_validators
@@ -49,7 +48,6 @@ from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
-CONF_BATTERY_LEVEL = "battery_level"
CONF_CLEAN_SEGMENTS = "clean_segments"
CONF_FAN_SPEED = "fan_speed"
CONF_FAN_SPEED_LIST = "fan_speeds"
@@ -75,7 +73,6 @@ CLEAN_AREA_GROUP = "clean_area_group"
VACUUM_COMMON_SCHEMA = vol.Schema(
{
- vol.Optional(CONF_BATTERY_LEVEL): cv.template,
vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list,
vol.Optional(CONF_FAN_SPEED): cv.template,
vol.Optional(CONF_STATE): cv.template,
@@ -164,26 +161,6 @@ def async_create_preview_vacuum(
)
-def create_issue(
- hass: HomeAssistant, supported_features: int, name: str, entity_id: str
-) -> None:
- """Create the battery_level issue."""
- if supported_features & VacuumEntityFeature.BATTERY:
- key = "deprecated_battery_level"
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"{key}_{entity_id}",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key=key,
- translation_placeholders={
- "entity_name": name,
- "entity_id": entity_id,
- },
- )
-
-
def validate_segments(
entity: AbstractTemplateVacuum,
option: str,
@@ -266,11 +243,6 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
self, CONF_FAN_SPEED, self._attr_fan_speed_list
),
)
- self.setup_template(
- CONF_BATTERY_LEVEL,
- "_attr_battery_level",
- template_validators.number(self, CONF_BATTERY_LEVEL, 0.0, 100.0),
- )
self.setup_template(
CONF_SEGMENTS,
@@ -283,9 +255,6 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
VacuumEntityFeature.START | VacuumEntityFeature.STATE
)
- if CONF_BATTERY_LEVEL in self._templates:
- self._attr_supported_features |= VacuumEntityFeature.BATTERY
-
for action_id, supported_feature in (
(SERVICE_START, 0),
(SERVICE_PAUSE, VacuumEntityFeature.PAUSE),
@@ -419,17 +388,6 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum):
assert name is not None
AbstractTemplateVacuum.__init__(self, name, config)
- @override
- async def async_added_to_hass(self) -> None:
- """Run when entity about to be added to hass."""
- await super().async_added_to_hass()
- create_issue(
- self.hass,
- self._attr_supported_features,
- self._attr_name or DEFAULT_NAME,
- self.entity_id,
- )
-
class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum):
"""Vacuum entity based on trigger data."""
@@ -446,14 +404,3 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum):
TriggerEntity.__init__(self, hass, coordinator, config)
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
AbstractTemplateVacuum.__init__(self, name, config)
-
- @override
- async def async_added_to_hass(self) -> None:
- """Run when entity about to be added to hass."""
- await super().async_added_to_hass()
- create_issue(
- self.hass,
- self._attr_supported_features,
- self._attr_name or DEFAULT_NAME,
- self.entity_id,
- )
diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py
index 836bb06d985f..91a13fe176d6 100644
--- a/homeassistant/components/tesla_fleet/button.py
+++ b/homeassistant/components/tesla_fleet/button.py
@@ -8,9 +8,11 @@ from tesla_fleet_api.const import Scope
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TeslaFleetConfigEntry
+from .const import DOMAIN
from .entity import TeslaFleetVehicleEntity
from .helpers import handle_vehicle_command
from .models import TeslaFleetVehicleData
@@ -48,10 +50,7 @@ DESCRIPTIONS: tuple[TeslaFleetButtonEntityDescription, ...] = (
),
TeslaFleetButtonEntityDescription(
key="homelink",
- func=lambda self: self.api.trigger_homelink(
- lat=self.coordinator.data["drive_state_latitude"],
- lon=self.coordinator.data["drive_state_longitude"],
- ),
+ func=lambda self: self.async_trigger_homelink(),
),
)
@@ -89,6 +88,17 @@ class TeslaFleetButtonEntity(TeslaFleetVehicleEntity, ButtonEntity):
def _async_update_attrs(self) -> None:
"""Update the attributes of the entity."""
+ async def async_trigger_homelink(self) -> Any:
+ """Trigger Homelink, which requires the vehicle location."""
+ if (lat := self.coordinator.data.get("drive_state_latitude")) is None or (
+ lon := self.coordinator.data.get("drive_state_longitude")
+ ) is None:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="homelink_no_location",
+ )
+ return await self.api.trigger_homelink(lat=lat, lon=lon)
+
@override
async def async_press(self) -> None:
"""Press the button."""
diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py
index 4b4df4754216..825af496c660 100644
--- a/homeassistant/components/tesla_fleet/device_tracker.py
+++ b/homeassistant/components/tesla_fleet/device_tracker.py
@@ -4,6 +4,7 @@ from typing import override
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -52,8 +53,8 @@ class TeslaFleetDeviceTrackerEntity(
and self._attr_latitude is None
and self._attr_longitude is None
):
- self._attr_latitude = state.attributes.get("latitude")
- self._attr_longitude = state.attributes.get("longitude")
+ self._attr_latitude = state.attributes.get(EntityStateAttribute.LATITUDE)
+ self._attr_longitude = state.attributes.get(EntityStateAttribute.LONGITUDE)
class TeslaFleetDeviceTrackerLocationEntity(TeslaFleetDeviceTrackerEntity):
diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json
index 6fdf4a6b6b7f..300c29ad0b3a 100644
--- a/homeassistant/components/tesla_fleet/manifest.json
+++ b/homeassistant/components/tesla_fleet/manifest.json
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "requirements": ["tesla-fleet-api==1.5.2"]
+ "requirements": ["tesla-fleet-api==1.7.2"]
}
diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py
index 0df38ebf15b5..3b2ebac8d4e2 100644
--- a/homeassistant/components/tesla_fleet/media_player.py
+++ b/homeassistant/components/tesla_fleet/media_player.py
@@ -76,14 +76,11 @@ class TeslaFleetMediaEntity(TeslaFleetVehicleEntity, MediaPlayerEntity):
self._attr_state = STATES.get(
self.get("vehicle_state_media_info_media_playback_status") or "Off",
)
+ # volume_level is audio_volume / audio_volume_max, so one notch as a
+ # fraction of range is the per-notch increment divided by the max.
self._attr_volume_step = (
- 1.0
- / self._volume_max
- / (
- self.get("vehicle_state_media_info_audio_volume_increment")
- or VOLUME_STEP
- )
- )
+ self.get("vehicle_state_media_info_audio_volume_increment") or VOLUME_STEP
+ ) / self._volume_max
if volume := self.get("vehicle_state_media_info_audio_volume"):
self._attr_volume_level = volume / self._volume_max
diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py
index a4d08e49c6b6..fa8ccaf9c974 100644
--- a/homeassistant/components/tesla_fleet/sensor.py
+++ b/homeassistant/components/tesla_fleet/sensor.py
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
+ SensorEntityStateAttribute,
SensorStateClass,
)
from homeassistant.const import (
@@ -526,7 +527,12 @@ class TeslaFleetVehicleSensorEntity(TeslaFleetVehicleEntity, RestoreSensor):
if (
self.entity_description.key in CHARGE_ENERGY_RESET_KEYS
and (last_state := await self.async_get_last_state()) is not None
- and (last_reset := last_state.attributes.get("last_reset")) is not None
+ and (
+ last_reset := last_state.attributes.get(
+ SensorEntityStateAttribute.LAST_RESET
+ )
+ )
+ is not None
):
self._attr_last_reset = dt_util.parse_datetime(str(last_reset))
diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json
index 3e36a827e5c9..8b9cfe120569 100644
--- a/homeassistant/components/tesla_fleet/strings.json
+++ b/homeassistant/components/tesla_fleet/strings.json
@@ -609,6 +609,9 @@
"command_reason": {
"message": "Command was unsuccessful: {reason}"
},
+ "homelink_no_location": {
+ "message": "Vehicle location is not available. Ensure the vehicle is awake and that the location scope has been granted."
+ },
"invalid_cop_temp": {
"message": "Cabin overheat protection does not support that temperature."
},
diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py
index 480441bf46b8..c5ca5d0c93b8 100644
--- a/homeassistant/components/tesla_wall_connector/__init__.py
+++ b/homeassistant/components/tesla_wall_connector/__init__.py
@@ -8,11 +8,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from .const import CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE
from .coordinator import (
WallConnectorConfigEntry,
WallConnectorCoordinator,
WallConnectorData,
- get_poll_interval,
)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -24,7 +24,11 @@ async def async_setup_entry(
"""Set up Tesla Wall Connector from a config entry."""
hostname = entry.data[CONF_HOST]
- wall_connector = WallConnector(host=hostname, session=async_get_clientsession(hass))
+ wall_connector = WallConnector(
+ host=hostname,
+ session=async_get_clientsession(hass),
+ split_phase=entry.options.get(CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE),
+ )
try:
version_data = await wall_connector.async_get_version()
@@ -45,16 +49,9 @@ async def async_setup_entry(
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
-
return True
-async def update_listener(hass: HomeAssistant, entry: WallConnectorConfigEntry) -> None:
- """Handle options update."""
- entry.runtime_data.update_coordinator.update_interval = get_poll_interval(entry)
-
-
async def async_unload_entry(
hass: HomeAssistant, entry: WallConnectorConfigEntry
) -> bool:
diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py
index f7965760ca5a..aa1b9c24c0bd 100644
--- a/homeassistant/components/tesla_wall_connector/config_flow.py
+++ b/homeassistant/components/tesla_wall_connector/config_flow.py
@@ -7,17 +7,57 @@ from tesla_wall_connector import WallConnector
from tesla_wall_connector.exceptions import WallConnectorError
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlowWithReload,
+)
from homeassistant.const import CONF_HOST
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import BooleanSelector
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER
+from .const import (
+ CONF_SPLIT_PHASE,
+ DEFAULT_SPLIT_PHASE,
+ DOMAIN,
+ WALLCONNECTOR_DEVICE_NAME,
+ WALLCONNECTOR_SERIAL_NUMBER,
+)
+from .coordinator import WallConnectorConfigEntry
_LOGGER = logging.getLogger(__name__)
+class TeslaWallConnectorOptionsFlow(OptionsFlowWithReload):
+ """Handle Tesla Wall Connector options."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options."""
+ if user_input is not None:
+ return self.async_create_entry(
+ title="",
+ data={CONF_SPLIT_PHASE: user_input[CONF_SPLIT_PHASE]},
+ )
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_SPLIT_PHASE,
+ default=self.config_entry.options.get(
+ CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE
+ ),
+ ): BooleanSelector(),
+ }
+ ),
+ )
+
+
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect.
@@ -45,6 +85,15 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN):
super().__init__()
self.ip_address: str | None = None
+ @staticmethod
+ @callback
+ @override
+ def async_get_options_flow(
+ _config_entry: WallConnectorConfigEntry,
+ ) -> TeslaWallConnectorOptionsFlow:
+ """Get the options flow."""
+ return TeslaWallConnectorOptionsFlow()
+
@override
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
@@ -91,7 +140,12 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
data_schema = vol.Schema(
- {vol.Required(CONF_HOST, default=self.ip_address): str}
+ {
+ vol.Required(CONF_HOST, default=self.ip_address): str,
+ vol.Optional(
+ CONF_SPLIT_PHASE, default=DEFAULT_SPLIT_PHASE
+ ): BooleanSelector(),
+ }
)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=data_schema)
@@ -110,11 +164,17 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id=info[WALLCONNECTOR_SERIAL_NUMBER], raise_on_progress=True
)
self._abort_if_unique_id_configured(
- updates=user_input, reload_on_update=True
+ updates={CONF_HOST: user_input[CONF_HOST]}, reload_on_update=True
)
- return self.async_create_entry(title=info["title"], data=user_input)
+ return self.async_create_entry(
+ title=info["title"],
+ data={CONF_HOST: user_input[CONF_HOST]},
+ options={CONF_SPLIT_PHASE: user_input[CONF_SPLIT_PHASE]},
+ )
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/tesla_wall_connector/const.py b/homeassistant/components/tesla_wall_connector/const.py
index 2a660ee1aae9..4be4a41d6c1f 100644
--- a/homeassistant/components/tesla_wall_connector/const.py
+++ b/homeassistant/components/tesla_wall_connector/const.py
@@ -2,10 +2,18 @@
DOMAIN = "tesla_wall_connector"
DEFAULT_SCAN_INTERVAL = 30
+DEFAULT_SPLIT_PHASE = False
+
+CONF_SPLIT_PHASE = "split_phase"
WALLCONNECTOR_SERIAL_NUMBER = "serial_number"
WALLCONNECTOR_DATA_VITALS = "vitals"
WALLCONNECTOR_DATA_LIFETIME = "lifetime"
+WALLCONNECTOR_DATA_WIFI_STATUS = "wifi_status"
-WALLCONNECTOR_DEVICE_NAME = "Tesla Wall Connector"
+WALLCONNECTOR_DEVICE_MANUFACTURER = "Tesla"
+WALLCONNECTOR_DEVICE_MODEL = "Wall Connector"
+WALLCONNECTOR_DEVICE_NAME = (
+ f"{WALLCONNECTOR_DEVICE_MANUFACTURER} {WALLCONNECTOR_DEVICE_MODEL}"
+)
diff --git a/homeassistant/components/tesla_wall_connector/coordinator.py b/homeassistant/components/tesla_wall_connector/coordinator.py
index 61e1416f5f6a..9d0080da0824 100644
--- a/homeassistant/components/tesla_wall_connector/coordinator.py
+++ b/homeassistant/components/tesla_wall_connector/coordinator.py
@@ -1,5 +1,6 @@
"""DataUpdateCoordinator for the Tesla Wall Connector integration."""
+import asyncio
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -13,7 +14,6 @@ from tesla_wall_connector.exceptions import (
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -21,6 +21,7 @@ from .const import (
DEFAULT_SCAN_INTERVAL,
WALLCONNECTOR_DATA_LIFETIME,
WALLCONNECTOR_DATA_VITALS,
+ WALLCONNECTOR_DATA_WIFI_STATUS,
)
_LOGGER = logging.getLogger(__name__)
@@ -40,13 +41,6 @@ class WallConnectorData:
serial_number: str
-def get_poll_interval(entry: ConfigEntry) -> timedelta:
- """Get the poll interval from config."""
- return timedelta(
- seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
- )
-
-
class WallConnectorCoordinator(DataUpdateCoordinator[dict]):
"""Class to manage fetching Tesla Wall Connector data."""
@@ -65,7 +59,7 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]):
_LOGGER,
config_entry=entry,
name="tesla-wallconnector",
- update_interval=get_poll_interval(entry),
+ update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
self._hostname = hostname
self._wall_connector = wall_connector
@@ -74,8 +68,11 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]):
async def _async_update_data(self) -> dict:
"""Fetch new data from the Wall Connector."""
try:
- vitals = await self._wall_connector.async_get_vitals()
- lifetime = await self._wall_connector.async_get_lifetime()
+ vitals, lifetime, wifi_status = await asyncio.gather(
+ self._wall_connector.async_get_vitals(),
+ self._wall_connector.async_get_lifetime(),
+ self._wall_connector.async_get_wifi_status(),
+ )
except WallConnectorConnectionTimeoutError as ex:
raise UpdateFailed(
f"Could not fetch data from Tesla WallConnector at {self._hostname}:"
@@ -95,4 +92,5 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]):
return {
WALLCONNECTOR_DATA_VITALS: vitals,
WALLCONNECTOR_DATA_LIFETIME: lifetime,
+ WALLCONNECTOR_DATA_WIFI_STATUS: wifi_status,
}
diff --git a/homeassistant/components/tesla_wall_connector/entity.py b/homeassistant/components/tesla_wall_connector/entity.py
index da412aeeeac9..5254654274cb 100644
--- a/homeassistant/components/tesla_wall_connector/entity.py
+++ b/homeassistant/components/tesla_wall_connector/entity.py
@@ -7,7 +7,12 @@ from typing import Any, override
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME
+from .const import (
+ DOMAIN,
+ WALLCONNECTOR_DEVICE_MANUFACTURER,
+ WALLCONNECTOR_DEVICE_MODEL,
+ WALLCONNECTOR_DEVICE_NAME,
+)
from .coordinator import WallConnectorCoordinator, WallConnectorData
@@ -43,7 +48,9 @@ class WallConnectorEntity(CoordinatorEntity[WallConnectorCoordinator]):
return DeviceInfo(
identifiers={(DOMAIN, self.wall_connector_data.serial_number)},
name=WALLCONNECTOR_DEVICE_NAME,
- model=self.wall_connector_data.part_number,
+ manufacturer=WALLCONNECTOR_DEVICE_MANUFACTURER,
+ model=WALLCONNECTOR_DEVICE_MODEL,
+ model_id=self.wall_connector_data.part_number,
+ serial_number=self.wall_connector_data.serial_number,
sw_version=self.wall_connector_data.firmware_version,
- manufacturer="Tesla",
)
diff --git a/homeassistant/components/tesla_wall_connector/manifest.json b/homeassistant/components/tesla_wall_connector/manifest.json
index d008d99f1c16..223f6e904d6a 100644
--- a/homeassistant/components/tesla_wall_connector/manifest.json
+++ b/homeassistant/components/tesla_wall_connector/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "tesla_wall_connector",
"name": "Tesla Wall Connector",
- "codeowners": ["@einarhauks"],
+ "codeowners": ["@einarhauks", "@sarabveer"],
"config_flow": true,
"dhcp": [
{
@@ -21,5 +21,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["tesla_wall_connector"],
- "requirements": ["tesla-wall-connector==1.1.0"]
+ "requirements": ["tesla-wall-connector==1.2.0"]
}
diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py
index 9105ac493cc4..f76a4088a1a3 100644
--- a/homeassistant/components/tesla_wall_connector/sensor.py
+++ b/homeassistant/components/tesla_wall_connector/sensor.py
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
+ SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
@@ -21,8 +22,13 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
-from .const import WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS
+from .const import (
+ WALLCONNECTOR_DATA_LIFETIME,
+ WALLCONNECTOR_DATA_VITALS,
+ WALLCONNECTOR_DATA_WIFI_STATUS,
+)
from .coordinator import WallConnectorConfigEntry, WallConnectorData
from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin
@@ -48,6 +54,8 @@ class WallConnectorSensorDescription(
):
"""Sensor entity description with a function pointer for getting sensor value."""
+ suggested_object_id: str | None = None
+
WALL_CONNECTOR_SENSORS = [
WallConnectorSensorDescription(
@@ -111,6 +119,15 @@ WALL_CONNECTOR_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ WallConnectorSensorDescription(
+ key="vehicle_current_a",
+ translation_key="vehicle_current_a",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].vehicle_current_a,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
WallConnectorSensorDescription(
key="current_a_a",
translation_key="current_a_a",
@@ -185,12 +202,24 @@ WALL_CONNECTOR_SENSORS = [
),
WallConnectorSensorDescription(
key="energy_kWh",
+ translation_key="energy_kwh",
+ suggested_object_id="energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda data: data[WALLCONNECTOR_DATA_LIFETIME].energy_wh,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
+ WallConnectorSensorDescription(
+ key="wifi_rssi",
+ translation_key="wifi_rssi",
+ suggested_object_id="wifi_rssi",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ value_fn=lambda data: data[WALLCONNECTOR_DATA_WIFI_STATUS].wifi_rssi,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
]
@@ -226,7 +255,16 @@ class WallConnectorSensorEntity(WallConnectorEntity, SensorEntity):
@property
@override
- def native_value(self):
+ def suggested_object_id(self) -> str | None:
+ """Return suggested object id."""
+ if self.entity_description.suggested_object_id is not None:
+ return self.entity_description.suggested_object_id
+
+ return super().suggested_object_id
+
+ @property
+ @override
+ 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/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json
index 0269e85b1123..192d493d91e0 100644
--- a/homeassistant/components/tesla_wall_connector/strings.json
+++ b/homeassistant/components/tesla_wall_connector/strings.json
@@ -1,4 +1,8 @@
{
+ "common": {
+ "split_phase": "Single-phase / Split-phase electrical service",
+ "split_phase_description": "Enable if your Wall Connector is powered by single-phase / split-phase electrical service. This affects the calculation of the Total power sensor. Leave disabled for three-phase supply (default)."
+ },
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
@@ -11,10 +15,12 @@
"step": {
"user": {
"data": {
- "host": "[%key:common::config_flow::data::host%]"
+ "host": "[%key:common::config_flow::data::host%]",
+ "split_phase": "[%key:component::tesla_wall_connector::common::split_phase%]"
},
"data_description": {
- "host": "Hostname or IP address of your Tesla Wall Connector."
+ "host": "Hostname or IP address of your Tesla Wall Connector.",
+ "split_phase": "[%key:component::tesla_wall_connector::common::split_phase_description%]"
},
"title": "Configure Tesla Wall Connector"
}
@@ -39,6 +45,9 @@
"current_c_a": {
"name": "Phase C current"
},
+ "energy_kwh": {
+ "name": "Lifetime energy"
+ },
"grid_hz": {
"name": "Grid frequency"
},
@@ -78,6 +87,9 @@
"total_power_w": {
"name": "Total power"
},
+ "vehicle_current_a": {
+ "name": "Vehicle current"
+ },
"voltage_a_v": {
"name": "Phase A voltage"
},
@@ -86,6 +98,21 @@
},
"voltage_c_v": {
"name": "Phase C voltage"
+ },
+ "wifi_rssi": {
+ "name": "Wi-Fi RSSI"
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "split_phase": "[%key:component::tesla_wall_connector::common::split_phase%]"
+ },
+ "data_description": {
+ "split_phase": "[%key:component::tesla_wall_connector::common::split_phase_description%]"
+ }
}
}
}
diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py
index 26669ce64bf9..7ae42ab703ea 100644
--- a/homeassistant/components/teslemetry/__init__.py
+++ b/homeassistant/components/teslemetry/__init__.py
@@ -5,11 +5,12 @@ from collections.abc import Callable
from functools import partial
from typing import Any, Final, cast
-from aiohttp import ClientError, ClientResponseError
+from aiohttp import ClientError
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
Forbidden,
InvalidToken,
+ LoginRequired,
SubscriptionRequired,
TeslaFleetError,
)
@@ -20,10 +21,15 @@ from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryNotReady,
+ OAuth2TokenRequestError,
+ OAuth2TokenRequestReauthError,
+)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -89,18 +95,32 @@ async def _get_access_token(oauth_session: OAuth2Session) -> str:
oauth_session.valid_token,
oauth_session.token.get("expires_at"),
)
+ setup_in_progress = (
+ oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
+ )
try:
await oauth_session.async_ensure_token_valid()
- except ClientResponseError as err:
- if err.status == 401:
+ except OAuth2TokenRequestReauthError as err:
+ if setup_in_progress:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="not_ready_connection_error",
- ) from err
+ # Not in setup: let the coordinator's own OAuth2TokenRequestError
+ # handling stop polling and (re)start reauth without tearing
+ # down the already-loaded entry.
+ oauth_session.config_entry.async_start_reauth(oauth_session.hass)
+ raise
+ except OAuth2TokenRequestError as err:
+ # Recoverable (e.g. 429/5xx). During setup this backs off via the
+ # normal ConfigEntryNotReady retry; once loaded, let it propagate so
+ # the coordinator treats it as a transient failed update instead.
+ if setup_in_progress:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="not_ready_connection_error",
+ ) from err
+ raise
except (KeyError, TypeError) as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
@@ -265,6 +285,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
translation_domain=DOMAIN,
translation_key="auth_failed_invalid_token",
) from e
+ except LoginRequired as e:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed_login_required",
+ ) from e
except SubscriptionRequired as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
@@ -402,6 +427,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
translation_domain=DOMAIN,
translation_key="auth_failed_invalid_token",
) from e
+ except LoginRequired as e:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed_login_required",
+ ) from e
except SubscriptionRequired as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
@@ -478,10 +508,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
identifier in current_devices for identifier in device_entry.identifiers
):
LOGGER.debug("Removing stale device %s", device_entry.id)
- device_registry.async_update_device(
- device_id=device_entry.id,
- remove_config_entry_id=entry.entry_id,
- )
+ device_registry.async_remove_device(device_entry.id)
entry.runtime_data = TeslemetryData(
vehicles=vehicles,
diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py
index 05a92944e65e..e64af7b6e078 100644
--- a/homeassistant/components/teslemetry/binary_sensor.py
+++ b/homeassistant/components/teslemetry/binary_sensor.py
@@ -4,6 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import cast, override
+from tesla_fleet_api import firmware_at_least
from teslemetry_stream.vehicle import TeslemetryStreamVehicle
from homeassistant.components.binary_sensor import (
@@ -557,7 +558,7 @@ async def async_setup_entry(
if (
not vehicle.poll
and description.streaming_listener
- and vehicle.firmware >= description.streaming_firmware
+ and firmware_at_least(vehicle.firmware, description.streaming_firmware)
):
entities.append(
TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description)
diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py
index 3890e029cd1d..a06b9a9610bb 100644
--- a/homeassistant/components/teslemetry/climate.py
+++ b/homeassistant/components/teslemetry/climate.py
@@ -3,6 +3,7 @@
from itertools import chain
from typing import Any, cast, override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope
from tesla_fleet_api.teslemetry import Vehicle
@@ -11,6 +12,7 @@ from homeassistant.components.climate import (
HVAC_MODES,
ClimateEntity,
ClimateEntityFeature,
+ ClimateEntityStateAttribute,
HVACMode,
)
from homeassistant.const import (
@@ -65,7 +67,7 @@ async def async_setup_entry(
TeslemetryVehiclePollingClimateEntity(
vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes
)
- if vehicle.poll or vehicle.firmware < "2024.44.25"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25")
else TeslemetryStreamingClimateEntity(
vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes
)
@@ -75,7 +77,7 @@ async def async_setup_entry(
TeslemetryVehiclePollingCabinOverheatProtectionEntity(
vehicle, entry.runtime_data.scopes
)
- if vehicle.poll or vehicle.firmware < "2024.44.25"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25")
else TeslemetryStreamingCabinOverheatProtectionEntity(
vehicle, entry.runtime_data.scopes
)
@@ -212,7 +214,7 @@ class TeslemetryVehiclePollingClimateEntity(
value = self.get("climate_state_is_climate_on")
if value is None:
self._attr_hvac_mode = None
- if value:
+ elif value:
self._attr_hvac_mode = HVACMode.HEAT_COOL
else:
self._attr_hvac_mode = HVACMode.OFF
@@ -286,9 +288,15 @@ class TeslemetryStreamingClimateEntity(
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._attr_current_temperature = state.attributes.get(
+ ClimateEntityStateAttribute.CURRENT_TEMPERATURE
+ )
+ self._attr_target_temperature = state.attributes.get(
+ ClimateEntityStateAttribute.TEMPERATURE
+ )
+ self._attr_preset_mode = state.attributes.get(
+ ClimateEntityStateAttribute.PRESET_MODE
+ )
self.async_on_remove(
self.vehicle.stream_vehicle.listen_InsideTemp(
@@ -530,8 +538,12 @@ class TeslemetryStreamingCabinOverheatProtectionEntity(
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._attr_current_temperature = state.attributes.get(
+ ClimateEntityStateAttribute.CURRENT_TEMPERATURE
+ )
+ self._attr_target_temperature = state.attributes.get(
+ ClimateEntityStateAttribute.TEMPERATURE
+ )
self.async_on_remove(
self.vehicle.stream_vehicle.listen_InsideTemp(
diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py
index 0dffd2b73f6b..fa80e55ddb6f 100644
--- a/homeassistant/components/teslemetry/coordinator.py
+++ b/homeassistant/components/teslemetry/coordinator.py
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, override
from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint
from tesla_fleet_api.exceptions import (
GatewayTimeout,
+ InsufficientCredits,
InvalidResponse,
InvalidToken,
LoginRequired,
@@ -49,6 +50,10 @@ ENERGY_INFO_INTERVAL = timedelta(seconds=30)
ENERGY_HISTORY_INTERVAL = timedelta(seconds=60)
METADATA_INTERVAL = timedelta(hours=1)
+# Insufficient credits will not resolve themselves quickly, so back off polling
+# instead of hammering the API at the coordinator's normal interval.
+INSUFFICIENT_CREDITS_RETRY_AFTER = timedelta(hours=1).total_seconds()
+
ENDPOINTS = [
VehicleDataEndpoint.CHARGE_STATE,
VehicleDataEndpoint.CLIMATE_STATE,
@@ -139,6 +144,12 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"]
except (InvalidToken, SubscriptionRequired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
+ except InsufficientCredits as e:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed_insufficient_credits",
+ retry_after=INSUFFICIENT_CREDITS_RETRY_AFTER,
+ ) from e
except RETRY_EXCEPTIONS as e:
raise UpdateFailed(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py
index 55340c92e416..c718f3db8ffb 100644
--- a/homeassistant/components/teslemetry/cover.py
+++ b/homeassistant/components/teslemetry/cover.py
@@ -3,6 +3,7 @@
from itertools import chain
from typing import Any, override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand
from tesla_fleet_api.teslemetry import Vehicle
from teslemetry_stream import Signal
@@ -43,7 +44,7 @@ async def async_setup_entry(
chain(
(
TeslemetryVehiclePollingWindowEntity(vehicle, entry.runtime_data.scopes)
- if vehicle.poll or vehicle.firmware < "2024.26"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26")
else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes)
for vehicle in entry.runtime_data.vehicles
),
@@ -51,7 +52,7 @@ async def async_setup_entry(
TeslemetryVehiclePollingChargePortEntity(
vehicle, entry.runtime_data.scopes
)
- if vehicle.poll or vehicle.firmware < "2024.44.25"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25")
else TeslemetryStreamingChargePortEntity(
vehicle, entry.runtime_data.scopes
)
@@ -61,7 +62,7 @@ async def async_setup_entry(
TeslemetryVehiclePollingFrontTrunkEntity(
vehicle, entry.runtime_data.scopes
)
- if vehicle.poll or vehicle.firmware < "2024.26"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26")
else TeslemetryStreamingFrontTrunkEntity(
vehicle, entry.runtime_data.scopes
)
@@ -71,7 +72,7 @@ async def async_setup_entry(
TeslemetryVehiclePollingRearTrunkEntity(
vehicle, entry.runtime_data.scopes
)
- if vehicle.poll or vehicle.firmware < "2024.26"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26")
else TeslemetryStreamingRearTrunkEntity(
vehicle, entry.runtime_data.scopes
)
@@ -282,7 +283,8 @@ class TeslemetryVehiclePollingChargePortEntity(
@override
def _async_update_attrs(self) -> None:
"""Update the entity attributes."""
- self._attr_is_closed = not self._value
+ value = self._value
+ self._attr_is_closed = None if value is None else not value
class TeslemetryStreamingChargePortEntity(
@@ -355,7 +357,8 @@ class TeslemetryVehiclePollingFrontTrunkEntity(
@override
def _async_update_attrs(self) -> None:
"""Update the entity attributes."""
- self._attr_is_closed = self._value == CLOSED
+ value = self._value
+ self._attr_is_closed = None if value is None else value == CLOSED
class TeslemetryStreamingFrontTrunkEntity(
@@ -429,7 +432,8 @@ class TeslemetryVehiclePollingRearTrunkEntity(
@override
def _async_update_attrs(self) -> None:
"""Update the entity attributes."""
- self._attr_is_closed = self._value == CLOSED
+ value = self._value
+ self._attr_is_closed = None if value is None else value == CLOSED
class TeslemetryStreamingRearTrunkEntity(
diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py
index 4dfeec2776f1..97dcd8b1515d 100644
--- a/homeassistant/components/teslemetry/device_tracker.py
+++ b/homeassistant/components/teslemetry/device_tracker.py
@@ -4,6 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import Scope
from teslemetry_stream import TeslemetryStreamVehicle
from teslemetry_stream.const import TeslaLocation
@@ -12,6 +13,7 @@ from homeassistant.components.device_tracker import (
TrackerEntity,
TrackerEntityDescription,
)
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -78,7 +80,9 @@ async def async_setup_entry(
for vehicle in entry.runtime_data.vehicles:
for description in DESCRIPTIONS:
- if vehicle.poll or vehicle.firmware < description.streaming_firmware:
+ if vehicle.poll or not firmware_at_least(
+ vehicle.firmware, description.streaming_firmware
+ ):
if description.polling_prefix:
entities.append(
TeslemetryVehiclePollingDeviceTrackerEntity(
@@ -144,8 +148,8 @@ class TeslemetryStreamingDeviceTrackerEntity(
"""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_latitude = state.attributes.get("latitude")
- self._attr_longitude = state.attributes.get("longitude")
+ self._attr_latitude = state.attributes.get(EntityStateAttribute.LATITUDE)
+ self._attr_longitude = state.attributes.get(EntityStateAttribute.LONGITUDE)
self.async_on_remove(
self.entity_description.value_listener(
self.vehicle.stream_vehicle, self._location_callback
diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py
index c86220f45202..cf578db2ff95 100644
--- a/homeassistant/components/teslemetry/lock.py
+++ b/homeassistant/components/teslemetry/lock.py
@@ -3,6 +3,7 @@
from itertools import chain
from typing import Any, override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import Scope
from tesla_fleet_api.teslemetry import Vehicle
@@ -40,7 +41,7 @@ async def async_setup_entry(
TeslemetryVehiclePollingVehicleLockEntity(
vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes
)
- if vehicle.poll or vehicle.firmware < "2024.26"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26")
else TeslemetryStreamingVehicleLockEntity(
vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes
)
@@ -50,7 +51,7 @@ async def async_setup_entry(
TeslemetryVehiclePollingCableLockEntity(
vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes
)
- if vehicle.poll or vehicle.firmware < "2024.26"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26")
else TeslemetryStreamingCableLockEntity(
vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes
)
diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json
index d56b3c6a30e0..a446a43e20bf 100644
--- a/homeassistant/components/teslemetry/manifest.json
+++ b/homeassistant/components/teslemetry/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"integration_type": "hub",
"iot_class": "cloud_polling",
- "loggers": ["tesla-fleet-api"],
+ "loggers": ["tesla_fleet_api", "teslemetry_stream"],
"quality_scale": "platinum",
- "requirements": ["tesla-fleet-api==1.5.2", "teslemetry-stream==0.9.1"]
+ "requirements": ["tesla-fleet-api==1.7.2", "teslemetry-stream==0.9.1"]
}
diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py
index 561ae8251835..5c1d332c103d 100644
--- a/homeassistant/components/teslemetry/media_player.py
+++ b/homeassistant/components/teslemetry/media_player.py
@@ -2,6 +2,7 @@
from typing import override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import Scope
from tesla_fleet_api.teslemetry import Vehicle
@@ -9,6 +10,7 @@ from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
+ MediaPlayerEntityStateAttribute,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
@@ -53,7 +55,7 @@ async def async_setup_entry(
async_add_entities(
TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes)
- if vehicle.poll or vehicle.firmware < "2025.2.6"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2025.2.6")
else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes)
for vehicle in entry.runtime_data.vehicles
)
@@ -200,14 +202,30 @@ class TeslemetryStreamingMediaEntity(
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._attr_volume_level = state.attributes.get(
+ MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL
+ )
+ self._attr_media_title = state.attributes.get(
+ MediaPlayerEntityStateAttribute.MEDIA_TITLE
+ )
+ self._attr_media_artist = state.attributes.get(
+ MediaPlayerEntityStateAttribute.MEDIA_ARTIST
+ )
+ self._attr_media_album_name = state.attributes.get(
+ MediaPlayerEntityStateAttribute.MEDIA_ALBUM_NAME
+ )
+ self._attr_media_playlist = state.attributes.get(
+ MediaPlayerEntityStateAttribute.MEDIA_PLAYLIST
+ )
+ self._attr_media_duration = state.attributes.get(
+ MediaPlayerEntityStateAttribute.MEDIA_DURATION
+ )
+ self._attr_media_position = state.attributes.get(
+ MediaPlayerEntityStateAttribute.MEDIA_POSITION
+ )
+ self._attr_source = state.attributes.get(
+ MediaPlayerEntityStateAttribute.INPUT_SOURCE
+ )
self.async_write_ha_state()
diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py
index 88143c3e5d0c..1588bbe83e78 100644
--- a/homeassistant/components/teslemetry/number.py
+++ b/homeassistant/components/teslemetry/number.py
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from itertools import chain
from typing import Any, override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import Scope
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
from teslemetry_stream import TeslemetryStreamVehicle
@@ -142,7 +143,7 @@ async def async_setup_entry(
description,
entry.runtime_data.scopes,
)
- if vehicle.poll or vehicle.firmware < "2024.26"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26")
else TeslemetryStreamingNumberEntity(
vehicle,
description,
diff --git a/homeassistant/components/teslemetry/quality_scale.yaml b/homeassistant/components/teslemetry/quality_scale.yaml
index 941b7ba6779a..f62afec7093b 100644
--- a/homeassistant/components/teslemetry/quality_scale.yaml
+++ b/homeassistant/components/teslemetry/quality_scale.yaml
@@ -59,9 +59,7 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
- repair-issues:
- status: exempt
- comment: No issues to repair
+ repair-issues: done
stale-devices: done
# Platinum
async-dependency: done
diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py
index 2803c71ccf6b..3601c2d353ff 100644
--- a/homeassistant/components/teslemetry/select.py
+++ b/homeassistant/components/teslemetry/select.py
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from itertools import chain
from typing import Any, override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat
from tesla_fleet_api.teslemetry import Vehicle
from teslemetry_stream import TeslemetryStreamVehicle
@@ -176,6 +177,33 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = (
HIGH,
],
),
+ TeslemetrySelectEntityDescription(
+ # remote_seat_cooler_request uses 1-indexed positions (front-left=1,
+ # front-right=2), unlike the 0-indexed Seat enum used for heaters.
+ # Polled state comes from the seat_fan_front_* vehicle_data fields.
+ key="climate_state_seat_fan_front_left",
+ select_fn=lambda api, level: api.remote_seat_cooler_request(1, level),
+ supported_fn=lambda data: bool(data.get("has_seat_cooling")),
+ streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontLeft(y),
+ options=[
+ OFF,
+ LOW,
+ MEDIUM,
+ HIGH,
+ ],
+ ),
+ TeslemetrySelectEntityDescription(
+ key="climate_state_seat_fan_front_right",
+ select_fn=lambda api, level: api.remote_seat_cooler_request(2, level),
+ supported_fn=lambda data: bool(data.get("has_seat_cooling")),
+ streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontRight(y),
+ options=[
+ OFF,
+ LOW,
+ MEDIUM,
+ HIGH,
+ ],
+ ),
)
@@ -193,7 +221,7 @@ async def async_setup_entry(
vehicle, description, entry.runtime_data.scopes
)
if vehicle.poll
- or vehicle.firmware < "2024.26"
+ or not firmware_at_least(vehicle.firmware, "2024.26")
or description.streaming_listener is None
else TeslemetryStreamingSelectEntity(
vehicle, description, entry.runtime_data.scopes
@@ -261,10 +289,14 @@ class TeslemetryVehiclePollingSelectEntity(
def _async_update_attrs(self) -> None:
"""Handle updated data from the coordinator."""
self._climate = bool(self.get("climate_state_is_climate_on"))
- if not isinstance(self._value, int):
- self._attr_current_option = None
+ value = self._value
+ # Defensive clamp: Tesla could report a level outside the modeled
+ # range, so map it to the nearest known option rather than erroring.
+ if isinstance(value, int):
+ options = self.entity_description.options
+ self._attr_current_option = options[max(0, min(value, len(options) - 1))]
else:
- self._attr_current_option = self.entity_description.options[self._value]
+ self._attr_current_option = None
class TeslemetryStreamingSelectEntity(
@@ -308,10 +340,13 @@ class TeslemetryStreamingSelectEntity(
def _value_callback(self, value: int | None) -> None:
"""Update the value of the entity."""
- if value is None:
- self._attr_current_option = None
+ # Defensive clamp: Tesla could report a level outside the modeled
+ # range, so map it to the nearest known option rather than erroring.
+ if isinstance(value, int):
+ options = self.entity_description.options
+ self._attr_current_option = options[max(0, min(value, len(options) - 1))]
else:
- self._attr_current_option = self.entity_description.options[value]
+ self._attr_current_option = None
self.async_write_ha_state()
def _climate_callback(self, value: bool | None) -> None:
diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py
index d35c30863537..070f87c7848e 100644
--- a/homeassistant/components/teslemetry/sensor.py
+++ b/homeassistant/components/teslemetry/sensor.py
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, override
+from tesla_fleet_api import firmware_at_least
from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle
from homeassistant.components.sensor import (
@@ -1605,16 +1606,15 @@ async def async_setup_entry(
if (
not vehicle.poll
and description.streaming_listener
- and vehicle.firmware >= description.streaming_firmware
+ and firmware_at_least(vehicle.firmware, description.streaming_firmware)
):
entities.append(TeslemetryStreamSensorEntity(vehicle, description))
elif description.polling:
entities.append(TeslemetryVehicleSensorEntity(vehicle, description))
for time_description in VEHICLE_TIME_DESCRIPTIONS:
- if (
- not vehicle.poll
- and vehicle.firmware >= time_description.streaming_firmware
+ if not vehicle.poll and firmware_at_least(
+ vehicle.firmware, time_description.streaming_firmware
):
entities.append(
TeslemetryStreamTimeSensorEntity(vehicle, time_description)
diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json
index ffb9e9d9ccbd..84957296f254 100644
--- a/homeassistant/components/teslemetry/strings.json
+++ b/homeassistant/components/teslemetry/strings.json
@@ -363,6 +363,24 @@
}
},
"select": {
+ "climate_state_seat_fan_front_left": {
+ "name": "Seat cooler front left",
+ "state": {
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
+ }
+ },
+ "climate_state_seat_fan_front_right": {
+ "name": "Seat cooler front right",
+ "state": {
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
+ }
+ },
"climate_state_seat_heater_left": {
"name": "Seat heater front left",
"state": {
@@ -1113,6 +1131,9 @@
"auth_failed_invalid_token": {
"message": "Access token is invalid, please reauthenticate"
},
+ "auth_failed_login_required": {
+ "message": "Login is no longer valid, please reauthenticate"
+ },
"auth_failed_migration": {
"message": "Failed to migrate to OAuth, please reauthenticate"
},
@@ -1179,6 +1200,9 @@
"update_failed": {
"message": "Error fetching data from Teslemetry API: {message}"
},
+ "update_failed_insufficient_credits": {
+ "message": "Teslemetry account has insufficient command credits, pausing updates until credits are added"
+ },
"update_failed_invalid_data": {
"message": "Received invalid data from API"
},
diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py
index a9ac7a0ec3ea..05b3ea75c696 100644
--- a/homeassistant/components/teslemetry/switch.py
+++ b/homeassistant/components/teslemetry/switch.py
@@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import AutoSeat, Scope
from tesla_fleet_api.teslemetry import Vehicle
from teslemetry_stream import TeslemetryStreamVehicle
@@ -162,7 +163,9 @@ async def async_setup_entry(
for vehicle in entry.runtime_data.vehicles:
for description in VEHICLE_DESCRIPTIONS:
- if vehicle.poll or vehicle.firmware < description.streaming_firmware:
+ if vehicle.poll or not firmware_at_least(
+ vehicle.firmware, description.streaming_firmware
+ ):
if description.polling:
entities.append(
TeslemetryVehiclePollingVehicleSwitchEntity(
diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py
index 2a61df1fd095..c592070bba43 100644
--- a/homeassistant/components/teslemetry/update.py
+++ b/homeassistant/components/teslemetry/update.py
@@ -2,10 +2,15 @@
from typing import Any, override
+from tesla_fleet_api import firmware_at_least
from tesla_fleet_api.const import Scope
from tesla_fleet_api.teslemetry import Vehicle
-from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
+from homeassistant.components.update import (
+ UpdateEntity,
+ UpdateEntityFeature,
+ UpdateEntityStateAttribute,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -37,7 +42,7 @@ async def async_setup_entry(
async_add_entities(
TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes)
- if vehicle.poll or vehicle.firmware < "2024.44.25"
+ if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25")
else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes)
for vehicle in entry.runtime_data.vehicles
)
@@ -134,6 +139,7 @@ class TeslemetryStreamingUpdateEntity(
_download_percentage: int = 0
_install_percentage: int = 0
+ _scheduled: bool = False
def __init__(
self,
@@ -152,15 +158,24 @@ class TeslemetryStreamingUpdateEntity(
"""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_in_progress = state.attributes.get(
+ UpdateEntityStateAttribute.IN_PROGRESS, False
+ )
+ self._attr_update_percentage = state.attributes.get(
+ UpdateEntityStateAttribute.UPDATE_PERCENTAGE
+ )
+ self._attr_installed_version = state.attributes.get(
+ UpdateEntityStateAttribute.INSTALLED_VERSION
+ )
+ self._attr_latest_version = state.attributes.get(
+ UpdateEntityStateAttribute.LATEST_VERSION
+ )
self._attr_supported_features = UpdateEntityFeature(
state.attributes.get(
"supported_features", self._attr_supported_features
)
)
+ self._scheduled = self._attr_in_progress
self.async_write_ha_state()
self.async_on_remove(
@@ -216,7 +231,8 @@ class TeslemetryStreamingUpdateEntity(
) -> None:
"""Handle software update scheduled start time."""
- self._attr_in_progress = value is not None
+ self._scheduled = value is not None
+ self._async_update_progress()
self.async_write_ha_state()
def _async_handle_software_update_version(self, value: str | None) -> None:
@@ -237,12 +253,12 @@ class TeslemetryStreamingUpdateEntity(
def _async_update_progress(self) -> None:
"""Update the progress of the update."""
- if 1 < self._download_percentage < 100:
+ if 0 < self._download_percentage < 100:
self._attr_in_progress = True
self._attr_update_percentage = self._download_percentage
- elif self._install_percentage > 10:
+ elif 10 < self._install_percentage < 100:
self._attr_in_progress = True
self._attr_update_percentage = self._install_percentage
else:
- self._attr_in_progress = False
+ self._attr_in_progress = self._scheduled
self._attr_update_percentage = None
diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py
index 7237255375fa..30042482cb1b 100644
--- a/homeassistant/components/tessie/__init__.py
+++ b/homeassistant/components/tessie/__init__.py
@@ -50,6 +50,7 @@ PLATFORMS = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
+ Platform.TEXT,
Platform.UPDATE,
]
diff --git a/homeassistant/components/tessie/icons.json b/homeassistant/components/tessie/icons.json
index b90af3ddff03..4ba74f3eb330 100644
--- a/homeassistant/components/tessie/icons.json
+++ b/homeassistant/components/tessie/icons.json
@@ -291,6 +291,11 @@
"vehicle_state_valet_mode": {
"default": "mdi:bow-tie"
}
+ },
+ "text": {
+ "navigation_destination": {
+ "default": "mdi:map-marker"
+ }
}
}
}
diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json
index 7d653e6d4019..f47f3a18f8dc 100644
--- a/homeassistant/components/tessie/manifest.json
+++ b/homeassistant/components/tessie/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
"quality_scale": "silver",
- "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.5.2"]
+ "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.7.2"]
}
diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json
index 9be7124a85c8..36cce2ef326c 100644
--- a/homeassistant/components/tessie/strings.json
+++ b/homeassistant/components/tessie/strings.json
@@ -612,6 +612,11 @@
"name": "Valet mode"
}
},
+ "text": {
+ "navigation_destination": {
+ "name": "Navigation destination"
+ }
+ },
"update": {
"update": {
"name": "[%key:component::update::title%]"
diff --git a/homeassistant/components/tessie/text.py b/homeassistant/components/tessie/text.py
new file mode 100644
index 000000000000..0168c579a48b
--- /dev/null
+++ b/homeassistant/components/tessie/text.py
@@ -0,0 +1,42 @@
+"""Text platform for Tessie integration."""
+
+from typing import override
+
+from homeassistant.components.text import TextEntity, TextMode
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import TessieConfigEntry
+from .entity import TessieEntity
+from .models import TessieVehicleData
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: TessieConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Tessie Text platform from a config entry."""
+ async_add_entities(
+ TessieNavigationTextEntity(vehicle) for vehicle in entry.runtime_data.vehicles
+ )
+
+
+class TessieNavigationTextEntity(TessieEntity, TextEntity):
+ """Text entity to send a navigation destination to the vehicle."""
+
+ _attr_mode = TextMode.TEXT
+ _attr_native_max = 255
+ _attr_native_min = 1
+ _attr_native_value: str | None = None
+
+ def __init__(self, vehicle: TessieVehicleData) -> None:
+ """Initialize the navigation text entity."""
+ super().__init__(vehicle, "navigation_destination")
+
+ @override
+ async def async_set_value(self, value: str) -> None:
+ """Send a navigation destination to the vehicle."""
+ await self.run(self.api.navigation_request(value))
diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py
index eebfd29452b8..911f3c6aa391 100644
--- a/homeassistant/components/text/__init__.py
+++ b/homeassistant/components/text/__init__.py
@@ -41,7 +41,13 @@ SCAN_INTERVAL = timedelta(seconds=30)
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-__all__ = ["DOMAIN", "TextEntity", "TextEntityDescription", "TextMode"]
+__all__ = [
+ "DOMAIN",
+ "TextEntity",
+ "TextEntityCapabilityAttribute",
+ "TextEntityDescription",
+ "TextMode",
+]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py
index 9a7467bdcd55..dc56b848e72e 100644
--- a/homeassistant/components/thermopro/__init__.py
+++ b/homeassistant/components/thermopro/__init__.py
@@ -1,4 +1,5 @@
"""The ThermoPro Bluetooth integration."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from functools import partial
import logging
diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py
index 850c5ec37ccc..a6ddb29099b5 100644
--- a/homeassistant/components/thread/discovery.py
+++ b/homeassistant/components/thread/discovery.py
@@ -36,6 +36,7 @@ KNOWN_BRANDS: dict[str | None, str] = {
"OpenThread": "openthread",
"Samsung": "samsung",
"SmartThings": "smartthings",
+ "SMLIGHT": "smlight",
"Yeelight": "yeelight",
}
THREAD_TYPE = "_meshcop._udp.local."
diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py
index 695d73859603..1be37133e03e 100644
--- a/homeassistant/components/threshold/__init__.py
+++ b/homeassistant/components/threshold/__init__.py
@@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index 8bca15ad6978..de19c3cf1e71 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -7,7 +7,7 @@ from typing import Any, Self, override
import voluptuous as vol
-from homeassistant.const import (
+from homeassistant.const import ( # noqa: F401
ATTR_EDITABLE,
ATTR_ENTITY_ID,
CONF_ICON,
@@ -26,6 +26,8 @@ from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util import dt as dt_util
+from .const import TimerEntityStateAttribute
+
_LOGGER = logging.getLogger(__name__)
DOMAIN = "timer"
@@ -256,16 +258,20 @@ class Timer(collection.CollectionEntity, RestoreEntity):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
attrs: dict[str, Any] = {
- ATTR_DURATION: _format_timedelta(self._running_duration),
- ATTR_EDITABLE: self.editable,
- ATTR_LAST_TRANSITION: self._last_transition,
+ TimerEntityStateAttribute.DURATION: _format_timedelta(
+ self._running_duration
+ ),
+ TimerEntityStateAttribute.EDITABLE: self.editable,
+ TimerEntityStateAttribute.LAST_TRANSITION: self._last_transition,
}
if self._end is not None:
- attrs[ATTR_FINISHES_AT] = self._end.isoformat()
+ attrs[TimerEntityStateAttribute.FINISHES_AT] = self._end.isoformat()
if self._remaining is not None:
- attrs[ATTR_REMAINING] = _format_timedelta(self._remaining)
+ attrs[TimerEntityStateAttribute.REMAINING] = _format_timedelta(
+ self._remaining
+ )
if self._restore:
- attrs[ATTR_RESTORE] = self._restore
+ attrs[TimerEntityStateAttribute.RESTORE] = self._restore
return attrs
@@ -286,20 +292,26 @@ class Timer(collection.CollectionEntity, RestoreEntity):
# Begin restoring state
self._state = state.state
- self._last_transition = state.attributes.get(ATTR_LAST_TRANSITION)
+ self._last_transition = state.attributes.get(
+ TimerEntityStateAttribute.LAST_TRANSITION
+ )
# Nothing more to do if the timer is idle
if self._state == STATUS_IDLE:
return
- self._running_duration = cv.time_period(state.attributes[ATTR_DURATION])
+ self._running_duration = cv.time_period(
+ state.attributes[TimerEntityStateAttribute.DURATION]
+ )
# If the timer was paused, we restore the remaining time
if self._state == STATUS_PAUSED:
- self._remaining = cv.time_period(state.attributes[ATTR_REMAINING])
+ self._remaining = cv.time_period(
+ state.attributes[TimerEntityStateAttribute.REMAINING]
+ )
return
# If we get here, the timer must have been active so we need to decide what
# to do based on end time and the current time
- end = cv.datetime(state.attributes[ATTR_FINISHES_AT])
+ end = cv.datetime(state.attributes[TimerEntityStateAttribute.FINISHES_AT])
# If there is time remaining in the timer, restore the remaining time then
# start the timer
if (remaining := end - dt_util.utcnow().replace(microsecond=0)) > timedelta(0):
diff --git a/homeassistant/components/timer/const.py b/homeassistant/components/timer/const.py
new file mode 100644
index 000000000000..93480189f2dc
--- /dev/null
+++ b/homeassistant/components/timer/const.py
@@ -0,0 +1,14 @@
+"""Constants for the timer integration."""
+
+from enum import StrEnum
+
+
+class TimerEntityStateAttribute(StrEnum):
+ """State attributes for timer entities."""
+
+ DURATION = "duration"
+ EDITABLE = "editable"
+ LAST_TRANSITION = "last_transition"
+ FINISHES_AT = "finishes_at"
+ REMAINING = "remaining"
+ RESTORE = "restore"
diff --git a/homeassistant/components/timer/reproduce_state.py b/homeassistant/components/timer/reproduce_state.py
index 95cec586c3d9..10bef2a774d1 100644
--- a/homeassistant/components/timer/reproduce_state.py
+++ b/homeassistant/components/timer/reproduce_state.py
@@ -17,6 +17,7 @@ from . import (
STATUS_ACTIVE,
STATUS_IDLE,
STATUS_PAUSED,
+ TimerEntityStateAttribute,
)
_LOGGER = logging.getLogger(__name__)
@@ -45,15 +46,17 @@ async def _async_reproduce_state(
# Return if we are already at the right state.
if cur_state.state == state.state and cur_state.attributes.get(
ATTR_DURATION
- ) == state.attributes.get(ATTR_DURATION):
+ ) == state.attributes.get(TimerEntityStateAttribute.DURATION):
return
service_data = {ATTR_ENTITY_ID: state.entity_id}
if state.state == STATUS_ACTIVE:
service = SERVICE_START
- if ATTR_DURATION in state.attributes:
- service_data[ATTR_DURATION] = state.attributes[ATTR_DURATION]
+ if TimerEntityStateAttribute.DURATION in state.attributes:
+ service_data[ATTR_DURATION] = state.attributes[
+ TimerEntityStateAttribute.DURATION
+ ]
elif state.state == STATUS_PAUSED:
service = SERVICE_PAUSE
elif state.state == STATUS_IDLE:
diff --git a/homeassistant/components/timer/trigger.py b/homeassistant/components/timer/trigger.py
index 8f4152ce507f..28349cff3b2a 100644
--- a/homeassistant/components/timer/trigger.py
+++ b/homeassistant/components/timer/trigger.py
@@ -26,7 +26,8 @@ from homeassistant.helpers.trigger import (
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
-from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE
+from . import DOMAIN, STATUS_ACTIVE
+from .const import TimerEntityStateAttribute
CONF_REMAINING = "remaining"
@@ -86,7 +87,9 @@ class TimeRemainingTrigger(Trigger):
if to_state.state != STATUS_ACTIVE:
return
- finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT)
+ finishes_at_str = to_state.attributes.get(
+ TimerEntityStateAttribute.FINISHES_AT
+ )
if finishes_at_str is None:
return
@@ -166,19 +169,24 @@ class TimeRemainingTrigger(Trigger):
TRIGGERS: dict[str, type[Trigger]] = {
"cancelled": make_entity_target_state_trigger(
- {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "cancelled"
+ {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)},
+ "cancelled",
),
"finished": make_entity_target_state_trigger(
- {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "finished"
+ {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)},
+ "finished",
),
"paused": make_entity_target_state_trigger(
- {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "paused"
+ {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)},
+ "paused",
),
"restarted": make_entity_target_state_trigger(
- {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "restarted"
+ {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)},
+ "restarted",
),
"started": make_entity_target_state_trigger(
- {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started"
+ {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)},
+ "started",
),
"remaining_time_reached": TimeRemainingTrigger,
}
diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py
new file mode 100644
index 000000000000..189c03040fb2
--- /dev/null
+++ b/homeassistant/components/todo/llm.py
@@ -0,0 +1,122 @@
+"""LLM tools for the todo integration."""
+
+from operator import attrgetter
+from typing import Any, cast, override
+
+import voluptuous as vol
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er, intent
+from homeassistant.helpers.llm import (
+ LLM_API_ASSIST,
+ IntentTool,
+ LLMContext,
+ Tool,
+ ToolInput,
+)
+from homeassistant.util.json import JsonObjectType
+
+from .const import DOMAIN, TodoServices
+from .intent import (
+ INTENT_LIST_ADD_ITEM,
+ INTENT_LIST_COMPLETE_ITEM,
+ INTENT_LIST_REMOVE_ITEM,
+)
+
+# Intents owned by this integration that are exposed as LLM tools.
+LLM_INTENTS = (INTENT_LIST_ADD_ITEM, INTENT_LIST_COMPLETE_ITEM, INTENT_LIST_REMOVE_ITEM)
+
+
+class TodoGetItemsTool(Tool):
+ """LLM Tool allowing querying a to-do list."""
+
+ name = "todo_get_items"
+ description = (
+ "Query a to-do list to find out what items are on it. "
+ "Use this to answer questions like "
+ "'What's on my task list?' or "
+ "'Read my grocery list'. "
+ "Filters items by status (needs_action, completed, all)."
+ )
+
+ def __init__(self, todo_lists: list[str]) -> None:
+ """Init the get items tool."""
+ self.parameters = vol.Schema(
+ {
+ vol.Required("todo_list"): vol.In(todo_lists),
+ vol.Optional(
+ "status",
+ description=(
+ "Filter returned items by status,"
+ " by default returns incomplete"
+ " items"
+ ),
+ default="needs_action",
+ ): vol.In(["needs_action", "completed", "all"]),
+ }
+ )
+
+ @override
+ async def async_call(
+ self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
+ ) -> JsonObjectType:
+ """Query a to-do list."""
+ data = self.parameters(tool_input.tool_args)
+ result = intent.async_match_targets(
+ hass,
+ intent.MatchTargetsConstraints(
+ name=data["todo_list"],
+ domains=[DOMAIN],
+ assistant=llm_context.assistant,
+ ),
+ )
+ if not result.is_match:
+ return {"success": False, "error": "To-do list not found"}
+ entity_id = result.states[0].entity_id
+ service_data: dict[str, Any] = {"entity_id": entity_id}
+ status = data["status"]
+ # "all" means no status filter, which returns every item.
+ if status != "all":
+ service_data["status"] = status
+ service_result = await hass.services.async_call(
+ DOMAIN,
+ TodoServices.GET_ITEMS,
+ service_data,
+ context=llm_context.context,
+ blocking=True,
+ return_response=True,
+ )
+ if not service_result:
+ return {"success": False, "error": "To-do list not found"}
+ items = cast(dict, service_result)[entity_id]["items"]
+ return {"success": True, "result": items}
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return the todo LLM tools when a to-do list is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ entity_registry = er.async_get(hass)
+ names: list[str] = []
+ for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")):
+ if not async_should_expose(hass, llm_context.assistant, state.entity_id):
+ continue
+ entity_entry = entity_registry.async_get(state.entity_id)
+ names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state))
+
+ if not names:
+ return None
+
+ tools: list[Tool] = [TodoGetItemsTool(names)]
+ tools.extend(
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type in LLM_INTENTS
+ )
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py
index d03dc77a62f1..7067858ad264 100644
--- a/homeassistant/components/tomorrowio/config_flow.py
+++ b/homeassistant/components/tomorrowio/config_flow.py
@@ -21,11 +21,11 @@ from homeassistant.config_entries import (
)
from homeassistant.const import (
CONF_API_KEY,
- CONF_FRIENDLY_NAME,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_NAME,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -141,7 +141,9 @@ class TomorrowioConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_NAME] = DEFAULT_NAME
# Append zone name if it exists and we are using the default name
if zone_state := async_active_zone(self.hass, latitude, longitude):
- zone_name = zone_state.attributes[CONF_FRIENDLY_NAME]
+ zone_name = zone_state.attributes[
+ EntityStateAttribute.FRIENDLY_NAME
+ ]
user_input[CONF_NAME] += f" - {zone_name}"
try:
await TomorrowioV4(
diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json
index 699bb8a7d762..af29c09a021a 100644
--- a/homeassistant/components/totalconnect/manifest.json
+++ b/homeassistant/components/totalconnect/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["total_connect_client"],
- "requirements": ["total-connect-client==2025.12.2"]
+ "requirements": ["total-connect-client==2026.7"]
}
diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py
index c77237fca686..c1d64ce0cf9e 100644
--- a/homeassistant/components/tplink/vacuum.py
+++ b/homeassistant/components/tplink/vacuum.py
@@ -94,7 +94,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity):
_attr_supported_features = (
VacuumEntityFeature.STATE
- | VacuumEntityFeature.BATTERY
| VacuumEntityFeature.START
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
@@ -152,12 +151,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity):
"""Locate the device."""
await self._speaker_module.locate()
- @property
- @override
- def battery_level(self) -> int | None:
- """Return battery level."""
- return self._vacuum_module.battery
-
@override
def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py
index 559f9eab1851..a782ae004538 100644
--- a/homeassistant/components/tplink_omada/__init__.py
+++ b/homeassistant/components/tplink_omada/__init__.py
@@ -98,9 +98,7 @@ def _remove_old_devices(
(i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None
)
if mac and mac not in omada_devices:
- device_registry.async_update_device(
- registered_device.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(registered_device.id)
async def async_migrate_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool:
diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json
index 27ad50855a7d..8aa8663670ab 100644
--- a/homeassistant/components/tplink_omada/manifest.json
+++ b/homeassistant/components/tplink_omada/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
- "requirements": ["tplink-omada-client==1.5.8"]
+ "requirements": ["tplink-omada-client==1.5.9"]
}
diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py
index e8c151179ce5..9126e0d9db5d 100644
--- a/homeassistant/components/traccar/__init__.py
+++ b/homeassistant/components/traccar/__init__.py
@@ -1,4 +1,5 @@
"""Support for Traccar Client."""
+# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from http import HTTPStatus
from json import JSONDecodeError
diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py
index 45faad54767f..d260410f4338 100644
--- a/homeassistant/components/traccar/device_tracker.py
+++ b/homeassistant/components/traccar/device_tracker.py
@@ -5,8 +5,12 @@ from datetime import timedelta
import logging
from typing import override
-from homeassistant.components.device_tracker import TrackerEntity
+from homeassistant.components.device_tracker import (
+ TrackerEntity,
+ TrackerEntityStateAttribute,
+)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -16,12 +20,8 @@ from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN, TRACKER_UPDATE
from .const import (
- ATTR_ACCURACY,
ATTR_ALTITUDE,
- ATTR_BATTERY,
ATTR_BEARING,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
ATTR_SPEED,
EVENT_ALARM,
EVENT_ALL_EVENTS,
@@ -162,15 +162,17 @@ class TraccarEntity(TrackerEntity, RestoreEntity):
return
attr = state.attributes
- self._attr_latitude = attr.get(ATTR_LATITUDE)
- self._attr_longitude = attr.get(ATTR_LONGITUDE)
- self._attr_location_accuracy = attr.get(ATTR_ACCURACY, 0)
+ self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE)
+ self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE)
+ self._attr_location_accuracy = attr.get(
+ TrackerEntityStateAttribute.GPS_ACCURACY, 0
+ )
self._attr_extra_state_attributes = {
ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE),
ATTR_BEARING: attr.get(ATTR_BEARING),
ATTR_SPEED: attr.get(ATTR_SPEED),
}
- self._battery = attr.get(ATTR_BATTERY)
+ self._battery = attr.get(ATTR_BATTERY_LEVEL)
@override
async def async_will_remove_from_hass(self) -> None:
diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py
index c5a8549e91c0..a3f721fe6689 100644
--- a/homeassistant/components/trend/__init__.py
+++ b/homeassistant/components/trend/__init__.py
@@ -34,7 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py
index 48a7e5212f00..1dc7709a84a1 100644
--- a/homeassistant/components/tuya/__init__.py
+++ b/homeassistant/components/tuya/__init__.py
@@ -78,9 +78,7 @@ async def cleanup_device_registry(
):
for item in device_entry.identifiers:
if item[0] == DOMAIN and item[1] not in device_manager.device_map:
- device_registry.async_update_device(
- device_entry.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(device_entry.id)
break
diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py
index 7579b803286e..ba3f7115438e 100644
--- a/homeassistant/components/tuya/coordinator.py
+++ b/homeassistant/components/tuya/coordinator.py
@@ -3,6 +3,7 @@
from pathlib import Path
from typing import Any
+import requests
from tuya_device_handlers import TUYA_QUIRKS_REGISTRY
from tuya_device_handlers.devices import register_tuya_quirks
from tuya_sharing import (
@@ -14,7 +15,7 @@ from tuya_sharing import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
@@ -80,6 +81,9 @@ class DeviceListener(SharingDeviceListener):
# Get all devices from Tuya, makes blocking web calls
try:
manager.update_device_cache()
+ except requests.exceptions.ConnectionError as exc:
+ msg = "Unable to connect to Tuya"
+ raise ConfigEntryNotReady(msg) from exc
except Exception as exc:
# While in general, we should avoid catching broad exceptions,
# we have no other way of detecting this case.
diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json
index 18f5551f7fbd..5e9076c213ec 100644
--- a/homeassistant/components/tuya/manifest.json
+++ b/homeassistant/components/tuya/manifest.json
@@ -44,7 +44,7 @@
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": [
- "tuya-device-handlers==0.0.24",
+ "tuya-device-handlers==0.0.25",
"tuya-device-sharing-sdk==0.2.10"
]
}
diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py
index f3d088b42b1c..7f39d1207920 100644
--- a/homeassistant/components/unifi/const.py
+++ b/homeassistant/components/unifi/const.py
@@ -1,5 +1,6 @@
"""Constants for the UniFi Network integration."""
+from datetime import timedelta
import logging
from aiounifi.models.device import DeviceState
@@ -9,6 +10,12 @@ from homeassistant.const import Platform
LOGGER = logging.getLogger(__package__)
DOMAIN = "unifi"
+# The UniFi controller keeps a record of every client it has ever seen. On busy
+# or guest networks that is easily tens of thousands of drive-by devices.
+# Only inactive clients seen within this window are restored on startup, older
+# ones are pruned together with their device so the registry stops growing.
+CLIENT_RESTORE_MAX_AGE = timedelta(days=30)
+
PLATFORMS = [
Platform.BUTTON,
Platform.DEVICE_TRACKER,
diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py
index 067b8a44865f..14a1375a73b2 100644
--- a/homeassistant/components/unifi/hub/entity_loader.py
+++ b/homeassistant/components/unifi/hub/entity_loader.py
@@ -6,19 +6,21 @@ Make sure expected clients are available for platforms.
import asyncio
from collections.abc import Callable, Coroutine, Sequence
-from datetime import timedelta
+from datetime import datetime, timedelta
from functools import partial
from typing import TYPE_CHECKING, Any
from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent
+from aiounifi.models.client import Client
from homeassistant.const import Platform
from homeassistant.core import callback
-from homeassistant.helpers import entity_registry as er
+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.util import dt as dt_util
-from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS
+from ..const import CLIENT_RESTORE_MAX_AGE, LOGGER, UNIFI_WIRELESS_CLIENTS
from ..coordinator import UnifiDataUpdateCoordinator
from ..entity import UnifiEntity, UnifiEntityDescription
@@ -102,24 +104,77 @@ class UnifiEntityLoader:
@callback
def _restore_inactive_clients(self) -> None:
- """Restore inactive clients.
+ """Restore recently seen inactive clients and prune stale ones.
- Provide inactive clients to device tracker and switch platform.
+ The UniFi controller keeps a record of every client it has ever seen.
+ Only clients seen within the retention window, or explicitly selected
+ or blocked, are restored. Trackers falling outside that window are
+ removed together with their device so the registry does not grow
+ unbounded.
"""
config = self.hub.config
- entity_registry = er.async_get(self.hub.hass)
- macs: list[str] = [
- entry.unique_id.split("-", 1)[1]
- for entry in er.async_entries_for_config_entry(
- entity_registry, config.entry.entry_id
- )
- if entry.domain == Platform.DEVICE_TRACKER and "-" in entry.unique_id
- ]
api = self.hub.api
- for mac in config.option_supported_clients + config.option_block_clients + macs:
+ entity_registry = er.async_get(self.hub.hass)
+ device_registry = dr.async_get(self.hub.hass)
+
+ now = dt_util.utcnow()
+ always_restore = set(config.option_supported_clients)
+ always_restore.update(config.option_block_clients)
+
+ pruned = 0
+ for entry in er.async_entries_for_config_entry(
+ entity_registry, config.entry.entry_id
+ ):
+ if entry.domain != Platform.DEVICE_TRACKER or "-" not in entry.unique_id:
+ continue
+
+ mac = entry.unique_id.split("-", 1)[1]
+ if mac in api.clients or mac in always_restore:
+ continue
+
+ # Absent means the controller no longer reports it or the
+ # clients_all fetch failed this cycle. Never prune on that, a failed
+ # fetch would wipe every tracker and its device.
+ if (client := api.clients_all.get(mac)) is None:
+ continue
+
+ if not self._client_is_stale(client, now):
+ api.clients.process_raw([dict(client.raw)])
+ continue
+
+ self._remove_client(entity_registry, device_registry, entry.entity_id, mac)
+ pruned += 1
+
+ if pruned:
+ LOGGER.debug("Pruned %s stale UniFi client device(s)", pruned)
+
+ for mac in always_restore:
if mac not in api.clients and mac in api.clients_all:
api.clients.process_raw([dict(api.clients_all[mac].raw)])
+ @callback
+ def _client_is_stale(self, client: Client, now: datetime) -> bool:
+ """Return if a client has not been seen within the retention window."""
+ last_seen = dt_util.utc_from_timestamp(client.last_seen or 0)
+ return now - last_seen > CLIENT_RESTORE_MAX_AGE
+
+ @callback
+ def _remove_client(
+ self,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+ entity_id: str,
+ mac: str,
+ ) -> None:
+ """Remove a stale client's tracker entity and its device."""
+ entity_registry.async_remove(entity_id)
+ if device := device_registry.async_get_device(
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)}
+ ):
+ device_registry.async_update_device(
+ device.id, remove_config_entry_id=self.hub.config.entry.entry_id
+ )
+
@callback
def register_platform(
self,
diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py
index dae93b0a9c33..cc8e0fc234e5 100644
--- a/homeassistant/components/unifi/sensor.py
+++ b/homeassistant/components/unifi/sensor.py
@@ -50,6 +50,7 @@ from homeassistant.util import dt as dt_util, slugify
from . import UnifiConfigEntry
from .const import DEVICE_STATES
+from .device_tracker import async_client_allowed_fn
from .entity import (
UnifiEntity,
UnifiEntityDescription,
@@ -106,11 +107,16 @@ def async_client_uptime_value_fn(hub: UnifiHub, client: Client) -> datetime:
@callback
def async_wired_client_allowed_fn(hub: UnifiHub, obj_id: str) -> bool:
- """Check if client is wired and allowed."""
+ """Check if client is wired, tracked and reports a link speed.
+
+ Gate on the tracking options so the sensor (and its client device) is only
+ created for clients the user actually tracks, instead of every wired client
+ the controller has ever seen.
+ """
client = hub.api.clients[obj_id]
if not client.is_wired or client.wired_rate_mbps <= 0:
return False
- return True
+ return async_client_allowed_fn(hub, obj_id)
@callback
diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py
index adb67b35d27b..a17989b44871 100644
--- a/homeassistant/components/unifi_access/coordinator.py
+++ b/homeassistant/components/unifi_access/coordinator.py
@@ -297,10 +297,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
for identifier in device.identifiers
):
continue
- device_registry.async_update_device(
- device_id=device.id,
- remove_config_entry_id=self.config_entry.entry_id,
- )
+ device_registry.async_remove_device(device.id)
def _on_ws_connect(self) -> None:
"""Handle WebSocket connection established."""
diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py
index 86f379fd5521..9b66204c64ea 100644
--- a/homeassistant/components/unifiprotect/binary_sensor.py
+++ b/homeassistant/components/unifiprotect/binary_sensor.py
@@ -15,14 +15,18 @@ from uiprotect.data import (
SmartDetectObjectType,
)
from uiprotect.data.nvr import UOSDisk
-from uiprotect.data.public_devices import PublicDeviceModel, PublicSensor
+from uiprotect.data.public_devices import (
+ PublicDeviceModel,
+ PublicSensor,
+ SensorFeatureCapability,
+)
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.const import EntityCategory
+from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -37,6 +41,7 @@ from .entity import (
ProtectIsOnEntity,
ProtectNVREntity,
async_all_device_entities,
+ async_remove_unsupported_sense_entities,
)
_KEY_DOOR = "door"
@@ -49,6 +54,26 @@ def _async_motion_sensor_enabled_public(obj: PublicDeviceModel) -> bool:
return sensor.mount_type is not MountType.LEAK and sensor.motion_settings.is_enabled
+def _async_contact_sensor_enabled_public(obj: PublicDeviceModel) -> bool:
+ # Mirrors Sensor.is_contact_sensor_enabled over the public API.
+ return cast(PublicSensor, obj).is_contact_sensor_enabled
+
+
+def _async_leak_sensor_enabled_public(obj: PublicDeviceModel) -> bool:
+ # Leak-mounted (UP Sense), or the capability map advertises water_leak with a
+ # leak channel enabled — the USL family detects leaks without a leak mount.
+ # Settings alone are not a valid gate: sensors without the capability report
+ # inert default leak settings.
+ sensor = cast(PublicSensor, obj)
+ return sensor.is_leak_sensor_enabled or (
+ sensor.supports(SensorFeatureCapability.WATER_LEAK)
+ and (
+ sensor.leak_settings.is_internal_enabled
+ or sensor.leak_settings.is_external_enabled
+ )
+ )
+
+
@dataclasses.dataclass(frozen=True, kw_only=True)
class ProtectBinaryEntityDescription(
ProtectEntityDescription, BinarySensorEntityDescription
@@ -277,18 +302,18 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
key="dark",
translation_key="is_dark",
- ufp_value="is_dark",
+ ufp_public_value="is_dark",
),
ProtectBinaryEntityDescription(
key="motion",
device_class=BinarySensorDeviceClass.MOTION,
- ufp_value="is_pir_motion_detected",
+ ufp_public_value="is_pir_motion_detected",
),
ProtectBinaryEntityDescription(
key="light",
translation_key="flood_light",
entity_category=EntityCategory.DIAGNOSTIC,
- ufp_value="is_light_on",
+ ufp_public_value="is_light_on",
ufp_perm=PermRequired.NO_WRITE,
),
ProtectBinaryEntityDescription(
@@ -303,7 +328,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
key="status_light",
translation_key="status_light",
entity_category=EntityCategory.DIAGNOSTIC,
- ufp_value="light_device_settings.is_indicator_enabled",
+ ufp_public_value="light_device_settings.is_indicator_enabled",
ufp_perm=PermRequired.NO_WRITE,
),
)
@@ -315,8 +340,9 @@ MOUNTABLE_SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
key=_KEY_DOOR,
translation_key="contact",
device_class=BinarySensorDeviceClass.DOOR,
- ufp_value="is_opened",
- ufp_enabled="is_contact_sensor_enabled",
+ ufp_public_value="is_opened",
+ ufp_public_enabled_fn=_async_contact_sensor_enabled_public,
+ ufp_capability=SensorFeatureCapability.OPEN,
),
)
@@ -324,8 +350,9 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
key="leak",
device_class=BinarySensorDeviceClass.MOISTURE,
- ufp_value="is_leak_detected",
- ufp_enabled="is_leak_sensor_enabled",
+ ufp_public_value="is_leak_detected",
+ ufp_public_enabled_fn=_async_leak_sensor_enabled_public,
+ ufp_capability=SensorFeatureCapability.WATER_LEAK,
),
ProtectBinaryEntityDescription(
key="battery_low",
@@ -338,11 +365,13 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.MOTION,
ufp_public_value="is_motion_detected",
ufp_public_enabled_fn=_async_motion_sensor_enabled_public,
+ ufp_capability=SensorFeatureCapability.MOTION,
),
ProtectBinaryEntityDescription(
key="tampering",
device_class=BinarySensorDeviceClass.TAMPER,
- ufp_value="is_tampering_detected",
+ ufp_public_value="is_tampering_detected",
+ ufp_capability=SensorFeatureCapability.TAMPER,
),
ProtectBinaryEntityDescription(
key="status_light",
@@ -356,6 +385,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
translation_key="motion_detection_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="motion_settings.is_enabled",
+ ufp_capability=SensorFeatureCapability.MOTION,
ufp_perm=PermRequired.NO_WRITE,
),
ProtectBinaryEntityDescription(
@@ -568,8 +598,13 @@ class MountableProtectDeviceBinarySensor(ProtectDeviceBinarySensor):
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
super()._async_update_device_from_protect(device)
# UP Sense can be any of the 3 contact sensor device classes
+ mount_type = (
+ cast(PublicSensor, public).mount_type
+ if (public := self._ufp_public_obj) is not None
+ else self.device.mount_type
+ )
self._attr_device_class = MOUNT_DEVICE_CLASS_MAP.get(
- self.device.mount_type, BinarySensorDeviceClass.DOOR
+ mount_type, BinarySensorDeviceClass.DOOR
)
@@ -733,6 +768,9 @@ async def async_setup_entry(
) -> None:
"""Set up binary sensors for UniFi Protect integration."""
data = entry.runtime_data
+ async_remove_unsupported_sense_entities(
+ hass, Platform.BINARY_SENSOR, data, (*SENSE_SENSORS, *MOUNTABLE_SENSE_SENSORS)
+ )
@callback
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py
index 32f6238ee21f..c735bf00963d 100644
--- a/homeassistant/components/unifiprotect/camera.py
+++ b/homeassistant/components/unifiprotect/camera.py
@@ -1,19 +1,30 @@
"""Support for Ubiquiti's UniFi Protect NVR."""
+from collections.abc import Iterable
import logging
-from typing import override
+from typing import cast, override
from uiprotect.data import (
Camera as UFPCamera,
- CameraChannel,
+ ChannelQuality,
+ DeviceState,
ModelType,
ProtectAdoptableDeviceModel,
+ PublicDeviceModel,
StateType,
+ channel_id_for_quality,
)
+from uiprotect.data.public_devices import PublicCamera
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import issue_registry as ir
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import (
+ device_registry as dr,
+ entity_platform,
+ issue_registry as ir,
+)
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity
@@ -24,6 +35,7 @@ from .const import (
ATTR_FPS,
ATTR_HEIGHT,
ATTR_WIDTH,
+ DEFAULT_BRAND,
DOMAIN,
)
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
@@ -33,22 +45,31 @@ from .utils import async_ufp_instance_command, get_camera_base_name
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
+# Main (non-package) RTSPS quality tiers, in default-preference order.
+_MAIN_QUALITIES = (
+ ChannelQuality.HIGH,
+ ChannelQuality.MEDIUM,
+ ChannelQuality.LOW,
+)
+
@callback
def _create_rtsp_repair(
- hass: HomeAssistant, entry: UFPConfigEntry, camera: UFPCamera
+ hass: HomeAssistant, entry: UFPConfigEntry, public: PublicCamera
) -> None:
+ # Keyed on the public camera: the fix flow verifies and creates the stream
+ # through the public API, so it works without a private session too.
ir.async_create_issue(
hass,
DOMAIN,
- f"rtsp_disabled_{camera.id}",
+ f"rtsp_disabled_{public.id}",
is_fixable=True,
is_persistent=False,
learn_more_url="https://www.home-assistant.io/integrations/unifiprotect/#camera-streams",
severity=IssueSeverity.WARNING,
translation_key="rtsp_disabled",
- translation_placeholders={"camera": camera.display_name},
- data={"entry_id": entry.entry_id, "camera_id": camera.id},
+ translation_placeholders={"camera": public.display_name},
+ data={"entry_id": entry.entry_id, "camera_id": public.id},
)
@@ -58,74 +79,115 @@ def _async_camera_entities(
entry: UFPConfigEntry,
data: ProtectData,
ufp_device: UFPCamera | None = None,
+ public_device: PublicCamera | None = None,
) -> list[ProtectDeviceEntity]:
- """Create camera entities with stream URLs sourced from the public API.
+ """Create camera entities, enumerated public-master from ``PublicCamera``.
- One entity per *active* RTSPS quality (the first is enabled by default). The
- package channel is a snapshot-first view and is always exposed (disabled by
- default), streaming only when its quality is active. When no main quality is
- active the first non-package channel is still created so snapshots work, and
- a repair offers to activate its stream. RTSPS URLs come from the public API
- (the authoritative per-camera host, so stacked consoles resolve correctly)
- with SRTP stripped for go2rtc.
+ Stream URLs come from the public API because it carries the authoritative
+ per-camera host (stacked consoles resolve correctly), SRTP-stripped for
+ go2rtc.
"""
disable_stream = data.disable_stream
entities: list[ProtectDeviceEntity] = []
- cameras = data.get_cameras() if ufp_device is None else [ufp_device]
- for camera in cameras:
- if not camera.channels:
- if ufp_device is None:
- # only warn on startup
- _LOGGER.warning(
- "Camera does not have any channels: %s (id: %s)",
+
+ # Public-master enumeration: iterate the public camera list; the private
+ # camera is paired by shared id (fill) and is None in public-only mode.
+ pairs: Iterable[tuple[PublicCamera | None, UFPCamera | None]]
+ if public_device is not None:
+ private = (
+ None
+ if data.api.is_public_only
+ else data.api.bootstrap.cameras.get(public_device.id)
+ )
+ # mirror the startup enumeration's adopted filter
+ if private is not None and not private.is_adopted_by_us:
+ return entities
+ pairs = [(public_device, private)]
+ elif ufp_device is None:
+ pairs = data.get_public_cameras()
+ else:
+ adopted = data.async_get_public_device(ufp_device)
+ pairs = [(adopted if isinstance(adopted, PublicCamera) else None, ufp_device)]
+
+ for public, camera in pairs:
+ # A just-adopted camera not yet mirrored into the public bootstrap is
+ # deferred and picked up when enumeration re-runs.
+ if public is None:
+ if camera is not None:
+ _LOGGER.debug(
+ "Deferring camera %s until its public mirror arrives",
camera.display_name,
- camera.id,
)
- data.async_add_pending_camera_id(camera.id)
+ data.async_add_pending_camera_id(camera.id)
continue
- streams = data.get_rtsps_streams(camera.id)
- active = set(streams.get_active_stream_qualities()) if streams else set()
- issue_id = f"rtsp_disabled_{camera.id}"
+ # Hybrid: a camera not yet in the private bootstrap (adopt race) is
+ # skipped rather than built private-less — the adopt dispatch creates
+ # it with its private fill, which would otherwise collide on unique_id.
+ if camera is None and not data.api.is_public_only:
+ _LOGGER.debug(
+ "Deferring camera %s until its private object is adopted",
+ public.display_name,
+ )
+ continue
+ streams = data.get_rtsps_streams(public.id)
+ issue_id = f"rtsp_disabled_{public.id}"
+ tiers = public.hardware_stream_qualities()
+ main_qualities = [q for q in _MAIN_QUALITIES if q in tiers]
+ has_package = ChannelQuality.PACKAGE in tiers
+ if not main_qualities:
+ # The library guarantees the three main tiers; a camera without any
+ # is a broken contract — surface it loudly, but do not let one
+ # camera abort enumeration for the rest.
+ _LOGGER.warning(
+ "Camera %s reports no main stream tiers (%s); skipping",
+ public.display_name,
+ tiers,
+ )
+ continue
+
+ # Active stream tiers come from the public ``rtsps_streams`` object.
+ active = set(streams.get_active_stream_qualities()) if streams else set()
has_stream = False
- package_channel: CameraChannel | None = None
- for channel in camera.channels:
- if channel.is_package:
- package_channel = channel
- continue
- if channel.rtsps_quality in active:
+ for quality in main_qualities:
+ if quality in active:
entities.append(
- ProtectCamera(data, camera, channel, not has_stream, disable_stream)
+ ProtectCamera(
+ data, public, camera, quality, not has_stream, disable_stream
+ )
)
has_stream = True
# the package channel is a snapshot-first view (very low FPS); always
# expose it (disabled by default), streaming only when its quality is active
- if package_channel is not None:
+ if has_package:
entities.append(
- ProtectCamera(data, camera, package_channel, False, disable_stream)
+ ProtectCamera(
+ data, public, camera, ChannelQuality.PACKAGE, False, disable_stream
+ )
)
if has_stream:
ir.async_delete_issue(hass, DOMAIN, issue_id)
continue
- # no active main stream: expose the first non-package channel for snapshots
- fallback = next((c for c in camera.channels if not c.is_package), None)
- if fallback is None:
- continue
- entities.append(ProtectCamera(data, camera, fallback, True, disable_stream))
+ # no active main stream: expose the first main tier for snapshots
+ entities.append(
+ ProtectCamera(data, public, camera, main_qualities[0], True, disable_stream)
+ )
# no repair when the stream can't be enabled anyway: a disconnected
- # camera is streamless because it is offline, not because it needs one
+ # camera is streamless because it is offline, not because it needs one.
+ # The fix flow runs entirely on the public API, so public-only cameras
+ # get the repair too; third-party is only knowable with a private fill.
if (
disable_stream
- or camera.is_third_party_camera
- or camera.state is not StateType.CONNECTED
+ or public.state is not DeviceState.CONNECTED
+ or (camera is not None and camera.is_third_party_camera)
):
ir.async_delete_issue(hass, DOMAIN, issue_id)
else:
- _create_rtsp_repair(hass, entry, camera)
+ _create_rtsp_repair(hass, entry, public)
return entities
@@ -136,23 +198,28 @@ async def async_setup_entry(
) -> None:
"""Discover cameras on a UniFi Protect NVR."""
data = entry.runtime_data
+ platform = entity_platform.async_get_current_platform()
@callback
- def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
- # AiPort inherits from Camera but should not create camera entities
- if not isinstance(device, UFPCamera) or device.model is ModelType.AIPORT:
- return
- async_add_entities(_async_camera_entities(hass, entry, data, ufp_device=device))
+ def _add_new_device(device: ProtectAdoptableDeviceModel | PublicCamera) -> None:
+ if isinstance(device, PublicCamera):
+ entities = _async_camera_entities(hass, entry, data, public_device=device)
+ else:
+ # AiPort inherits from Camera but should not create camera entities
+ if not isinstance(device, UFPCamera) or device.model is ModelType.AIPORT:
+ return
+ entities = _async_camera_entities(hass, entry, data, ufp_device=device)
+ # A re-enumeration (deferred mirror, RTSPS prime) overlaps entities
+ # that already exist; the platform errors on live duplicates rather
+ # than deduplicating, so add only the missing ones.
+ live = {e.unique_id for e in platform.entities.values()}
+ async_add_entities([e for e in entities if e.unique_id not in live])
data.async_subscribe_adopt(_add_new_device)
entry.async_on_unload(
async_dispatcher_connect(hass, data.channels_signal, _add_new_device)
)
- # Clean up any erroneously created RTSP issues for AI Ports
- for device in data.get_by_types({ModelType.AIPORT}):
- ir.async_delete_issue(hass, DOMAIN, f"rtsp_disabled_{device.id}")
-
async_add_entities(_async_camera_entities(hass, entry, data))
@@ -168,24 +235,38 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
"_attr_available",
"_attr_is_recording",
"_attr_motion_detection_enabled",
+ # flips with the stream source (an RTSPS prime can be the only change)
+ "_attr_supported_features",
)
def __init__(
self,
data: ProtectData,
- camera: UFPCamera,
- channel: CameraChannel,
+ public: PublicCamera,
+ private: UFPCamera | None,
+ quality: ChannelQuality,
is_default: bool,
disable_stream: bool,
) -> None:
- """Initialize an UniFi camera."""
- self.channel = channel
+ """Initialize an UniFi camera.
+
+ The public camera is the master; the private camera fills gaps the
+ public API does not cover and is ``None`` in public-only mode.
+ """
+ self._public = public
+ self._public_missing = False
+ self._private = private
+ self._quality = quality
+ self._is_package = quality is ChannelQuality.PACKAGE
+ self._channel_id = channel_id_for_quality(quality)
self._disable_stream = disable_stream
self._last_image: bytes | None = None
- super().__init__(data, camera)
- self._attr_unique_id = f"{self.device.mac}_{channel.id}"
- self._attr_name = get_camera_base_name(channel)
- # only the default (first active) channel is enabled by default
+ # The base tracks the private device in hybrid (unchanged behaviour) and
+ # the public device in public-only, so it always has a mac to key on.
+ super().__init__(data, cast(ProtectDeviceType, private or public))
+ self._attr_unique_id = f"{self.device.mac}_{self._channel_id}"
+ self._attr_name = get_camera_base_name(quality)
+ # only the default (first active) quality channel is enabled by default
self._attr_entity_registry_enabled_default = is_default
# Set the stream source before finishing the init
# because async_added_to_hass is too late and camera
@@ -196,21 +277,17 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
@callback
def _async_set_stream_source(self) -> None:
"""Set the public-API RTSPS stream URL (SRTP stripped for go2rtc)."""
- quality = self.channel.rtsps_quality
- streams = self.data.get_rtsps_streams(self.device.id)
- if self._disable_stream or quality is None or streams is None:
+ quality = self._quality
+ streams = self.data.get_rtsps_streams(self._public.id)
+ if self._disable_stream or streams is None:
source = None
- if (
- streams is None
- and not self._disable_stream
- and not self.channel.is_package
- ):
+ if streams is None and not self._disable_stream and not self._is_package:
# online camera unexpectedly absent from the public bootstrap;
# log so this is distinguishable from an intentionally off stream
_LOGGER.debug(
"No public RTSPS data for camera %s (%s); using snapshots",
- self.device.display_name,
- self.device.id,
+ self._public.name,
+ self._public.id,
)
else:
source = streams.get_stream_url(quality, srtp=False)
@@ -219,43 +296,162 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
@callback
@override
- def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
- super()._async_update_device_from_protect(device)
- updated_device = self.device
- channel = updated_device.channels[self.channel.id]
- self.channel = channel
- motion_enabled = updated_device.recording_settings.enable_motion_detection
- self._attr_motion_detection_enabled = (
- motion_enabled if motion_enabled is not None else True
+ def _async_set_device_info(self) -> None:
+ if self._private is not None:
+ super()._async_set_device_info()
+ return
+ # public-only: no market_name/firmware_version/protect_url, and
+ # ``type`` only on newer firmware, so device identity is limited. The
+ # NVR link is omitted — an API-key-only client has no private
+ # bootstrap to read the NVR mac from, and resolving it publicly is
+ # async; the public-only config mode wires it at setup instead.
+ public = self._public
+ self._attr_device_info = DeviceInfo(
+ name=public.display_name,
+ model=public.type,
+ manufacturer=DEFAULT_BRAND,
+ connections={(dr.CONNECTION_NETWORK_MAC, public.mac)},
)
- state_type_is_connected = updated_device.state is StateType.CONNECTED
- self._attr_is_recording = (
- state_type_is_connected and updated_device.is_recording
- )
- is_connected = self.data.last_update_success and state_type_is_connected
- # some cameras have detachable lens that could cause the camera to be offline
- self._attr_available = is_connected and updated_device.is_video_ready
+ @callback
+ @override
+ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
+ if self._private is not None:
+ super()._async_update_device_from_protect(device)
+ updated_device = self.device
+ # A poll/resync can replace the bootstrap objects; follow them so
+ # commands and reads never act on a detached model.
+ self._private = updated_device
+ if isinstance(
+ public := self.data.async_get_public_device(updated_device),
+ PublicCamera,
+ ):
+ self._public = public
+ else:
+ # keep the last object for identity, but log so a vanished
+ # public mirror is observable rather than a silent no-op
+ _LOGGER.debug(
+ "Camera %s has no public mirror; keeping the last known one",
+ updated_device.display_name,
+ )
+ channel_id = self._channel_id
+ channel = (
+ updated_device.channels[channel_id]
+ if channel_id is not None and channel_id < len(updated_device.channels)
+ else None
+ )
+ if channel is None:
+ # A tier without its private channel blanks the diagnostics;
+ # log so a camera reconfiguration (or a quality that maps to no
+ # channel) is distinguishable from a bug.
+ _LOGGER.debug(
+ "Camera %s has no private channel %s; diagnostic attributes"
+ " unavailable",
+ updated_device.display_name,
+ channel_id,
+ )
+ motion_enabled = updated_device.recording_settings.enable_motion_detection
+ self._attr_motion_detection_enabled = (
+ motion_enabled if motion_enabled is not None else True
+ )
+ state_type_is_connected = updated_device.state is StateType.CONNECTED
+ self._attr_is_recording = (
+ state_type_is_connected and updated_device.is_recording
+ )
+ is_connected = self.data.last_update_success and state_type_is_connected
+ # some cameras have detachable lens that could make them offline
+ self._attr_available = is_connected and updated_device.is_video_ready
+
+ self._async_set_stream_source()
+ self._attr_extra_state_attributes = {
+ ATTR_WIDTH: channel.width if channel else None,
+ ATTR_HEIGHT: channel.height if channel else None,
+ ATTR_FPS: channel.fps if channel else None,
+ ATTR_BITRATE: channel.bitrate if channel else None,
+ ATTR_CHANNEL_ID: channel_id,
+ }
+ return
+
+ # public-only: recording/motion state and the per-stream diagnostics
+ # have no public equivalent and degrade; availability tracks the public
+ # devices websocket health and the public camera state.
+ public = self._public
+ self._attr_motion_detection_enabled = False
+ self._attr_is_recording = False
+ self._attr_available = (
+ self.data.last_public_update_success
+ and not self._public_missing
+ and public.state is DeviceState.CONNECTED
+ )
self._async_set_stream_source()
self._attr_extra_state_attributes = {
- ATTR_WIDTH: channel.width,
- ATTR_HEIGHT: channel.height,
- ATTR_FPS: channel.fps,
- ATTR_BITRATE: channel.bitrate,
- ATTR_CHANNEL_ID: channel.id,
+ ATTR_WIDTH: None,
+ ATTR_HEIGHT: None,
+ ATTR_FPS: None,
+ ATTR_BITRATE: None,
+ ATTR_CHANNEL_ID: self._channel_id,
}
+ @callback
+ def _async_public_camera_updated(self, obj: PublicDeviceModel | None) -> None:
+ """Handle a public devices websocket update for this camera.
+
+ ``obj`` is the refreshed public object, or ``None`` for a websocket
+ state change or an unmergeable frame, in which case it is re-read from
+ the public bootstrap. A camera missing from the bootstrap on re-read
+ has been removed and reads as unavailable until it reappears.
+ """
+ if obj is None:
+ obj = self.data.async_get_public_device(self._public)
+ if isinstance(obj, PublicCamera):
+ self._public = obj
+ self._public_missing = False
+ else:
+ self._public_missing = True
+ device = (
+ self._private
+ if self._private is not None
+ else cast(ProtectDeviceType, self._public)
+ )
+ self._async_updated_event(device)
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ # The stream URLs live on the public camera and change outside the
+ # private websocket (a background RTSPS prime announces itself on the
+ # public channel), so every camera tracks its public mirror; in
+ # public-only mode this is also the only state source.
+ self.async_on_remove(
+ self.data.async_subscribe_public(
+ self._public.mac, self._async_public_camera_updated
+ )
+ )
+ # A public update or delete can land between entity construction and
+ # this subscription; re-read so the entity does not start stale.
+ self._async_public_camera_updated(None)
+
@override
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
- """Return the Camera Image."""
- # Without a stream the camera is rendered by rapidly polling snapshots;
- # request low quality then to avoid hammering the console with large
- # images. width/height are unused (the public endpoint has no resize).
- high_quality = None if self._stream_source else False
- self._last_image = await self.device.get_public_api_snapshot(
- high_quality=high_quality, package=self.channel.is_package
+ """Return the Camera Image.
+
+ While snapshot-polling (no stream) request low quality to avoid
+ hammering the console. width/height are unused (the public endpoint
+ has no resize).
+ """
+ # Inlines the library's device-level default (support_full_hd_snapshot
+ # when streaming, low otherwise) since public-only has no private
+ # device object; the resolved value is unchanged.
+ high_quality = bool(
+ self._stream_source and self._public.feature_flags.support_full_hd_snapshot
+ )
+ self._last_image = await self.data.api.get_public_api_camera_snapshot(
+ camera_id=self._public.id,
+ high_quality=high_quality,
+ package=self._is_package,
)
return self._last_image
@@ -268,10 +464,20 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
@override
async def async_enable_motion_detection(self) -> None:
"""Call the job and enable motion detection."""
- await self.device.set_motion_detection(True)
+ await self._async_set_motion_detection(True)
@async_ufp_instance_command
@override
async def async_disable_motion_detection(self) -> None:
"""Call the job and disable motion detection."""
- await self.device.set_motion_detection(False)
+ await self._async_set_motion_detection(False)
+
+ async def _async_set_motion_detection(self, enabled: bool) -> None:
+ # the public API has no motion-detection setter; without a private
+ # session the command cannot be sent and must not report success.
+ if (private := self._private) is None:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="motion_detection_public_only",
+ )
+ await private.set_motion_detection(enabled)
diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py
index beaf24d29eda..717f960ca2f9 100644
--- a/homeassistant/components/unifiprotect/const.py
+++ b/homeassistant/components/unifiprotect/const.py
@@ -41,7 +41,6 @@ DEFAULT_VERIFY_SSL = False
DEFAULT_MAX_MEDIA = 1000
DEVICES_THAT_ADOPT = {
- ModelType.AIPORT,
ModelType.CAMERA,
ModelType.LIGHT,
ModelType.VIEWPORT,
diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py
index 577b47fd1867..7c68fd4b2809 100644
--- a/homeassistant/components/unifiprotect/data.py
+++ b/homeassistant/components/unifiprotect/data.py
@@ -8,6 +8,7 @@ from functools import partial
import logging
from typing import TYPE_CHECKING, Any, cast
+from aiohttp.client_exceptions import ServerDisconnectedError
from uiprotect import EventChange, ProtectApiClient, ProtectEvent
from uiprotect.api import RTSPSStreams
from uiprotect.data import (
@@ -19,8 +20,10 @@ from uiprotect.data import (
ProtectAdoptableDeviceModel,
PTZPatrol,
PublicDeviceModel,
+ WSAction,
WSSubscriptionMessage,
)
+from uiprotect.data.public_devices import PublicCamera
from uiprotect.exceptions import ClientError, NotAuthorized
from uiprotect.utils import log_event
from uiprotect.websocket import WebsocketState
@@ -152,6 +155,37 @@ class ProtectData:
Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted)
)
+ def get_public_cameras(
+ self,
+ ) -> Generator[tuple[PublicCamera | None, Camera | None]]:
+ """Iterate cameras public-master with private-fill.
+
+ The public bootstrap is the master list; the matching private camera is
+ paired by shared id when present (hybrid) and ``None`` in public-only
+ mode. An adopted private camera not (yet) mirrored into the public
+ bootstrap is yielded as ``(None, private)`` so the caller can defer it.
+ Adopted-filtering mirrors ``get_cameras`` whenever a private object is
+ available.
+ """
+ api = self.api
+ if not api.has_public_bootstrap:
+ return
+ # An API-key-only client never initializes the private bootstrap;
+ # accessing it would raise.
+ private_cameras: dict[str, Camera] = (
+ {} if api.is_public_only else api.bootstrap.cameras
+ )
+ public_cameras = api.public_bootstrap.cameras
+ for camera_id, public in public_cameras.items():
+ private = private_cameras.get(camera_id)
+ if private is not None and not private.is_adopted_by_us:
+ continue
+ yield public, private
+ for camera_id, private in private_cameras.items():
+ if camera_id in public_cameras or not private.is_adopted_by_us:
+ continue
+ yield None, private
+
async def async_load_ptz_patrols(self) -> None:
"""Load PTZ patrols for all PTZ cameras."""
await asyncio.gather(
@@ -232,11 +266,44 @@ class ProtectData:
self._async_signal_public_update(old_obj.mac, None)
return
if new_obj.model is ModelType.NVR:
- self._async_signal_device_update(self.api.bootstrap.nvr)
+ # An API-key-only client has no private NVR (reading it would raise).
+ if not self.api.is_public_only:
+ self._async_signal_device_update(self.api.bootstrap.nvr)
return
if isinstance(new_obj, PublicDeviceModel):
+ if new_obj.model is ModelType.CAMERA:
+ self._async_reenumerate_camera_on_public_change(new_obj, message)
self._async_signal_public_update(new_obj.mac, new_obj)
+ @callback
+ def _async_reenumerate_camera_on_public_change(
+ self, new_obj: PublicDeviceModel, message: WSSubscriptionMessage
+ ) -> None:
+ """Re-run camera enumeration when a public frame can add entities.
+
+ Three cases dispatch the public camera to the channels signal:
+
+ - A camera deferred at enumeration because its public mirror had not
+ arrived yet (the private channels-update path cannot be relied on to
+ fire again).
+ - A camera whose RTSPS streams the library primes in the background
+ after it comes online or is added, announced by an ``rtsps_streams``
+ change: the quality tiers that just became active still need their
+ entities.
+ - In public-only mode, a newly added camera — there is no private
+ adopt path that could discover it.
+
+ The platform adds only entities that do not exist yet, so overlapping
+ re-enumerations are safe.
+ """
+ if new_obj.id in self._pending_camera_ids:
+ self._pending_camera_ids.remove(new_obj.id)
+ elif "rtsps_streams" not in message.changed_data and not (
+ self.api.is_public_only and message.action is WSAction.ADD
+ ):
+ return
+ async_dispatcher_send(self._hass, self.channels_signal, new_obj)
+
@callback
def _async_process_public_event(
self, event: ProtectEvent, change: EventChange
@@ -245,18 +312,16 @@ class ProtectData:
Only the start of an event is dispatched, routed to the subscribers that
registered for this device and event type; an entity that cares about a
- sub-type (e.g. a smart-detect object type) filters further itself. The
- device is resolved by ``device_id`` (the stable cross-API join key), not
- the public ``device_mac``, so the key comes from the same store the
- entities derive ``self.device.mac`` from and matches without assuming
- both mac strings are byte-identical.
+ sub-type (e.g. a smart-detect object type) filters further itself.
+ Subscriptions are keyed by ``device_id`` (the stable cross-API join key,
+ shared by the private and public bootstraps), so the event routes
+ directly without a bootstrap lookup.
"""
if change is not EventChange.STARTED:
return
- device = self.api.bootstrap.get_device_from_id(event.device_id)
- if device is None or not (
+ if not (
subscriptions := self._public_event_subscriptions.get(
- (device.mac, event.type)
+ (event.device_id, event.type)
)
):
return
@@ -276,6 +341,37 @@ class ProtectData:
return
self.last_public_update_success = success
self._async_process_public_updates()
+ if success:
+ # The library resyncs its public bootstrap on reconnect, but the
+ # resync applies silently and races this callback, so the re-read
+ # above may see the pre-disconnect cache. Refresh again behind a
+ # guaranteed-fresh snapshot (``update_public`` is serialized) so a
+ # change from the disconnect gap cannot stay stale.
+ self._entry.async_create_background_task(
+ self._hass,
+ self._async_resignal_after_public_resync(),
+ "unifiprotect public reconnect refresh",
+ )
+
+ async def _async_resignal_after_public_resync(self) -> None:
+ """Re-signal public entities once a fresh public snapshot is applied."""
+ try:
+ await self.api.update_public()
+ except NotAuthorized:
+ # A revoked API key cannot self-recover.
+ self._entry.async_start_reauth(self._hass)
+ return
+ except (TimeoutError, ClientError, ServerDisconnectedError) as err:
+ # Transport errors retry on the next reconnect.
+ _LOGGER.debug("Public refresh after reconnect failed: %s", err)
+ return
+ self._async_process_public_updates()
+ # Existing subscriptions are refreshed above, but a camera that
+ # appeared (or gained streams) during the gap still needs its
+ # entities; the platform adds only the missing ones.
+ if self.api.has_public_bootstrap:
+ for public in list(self.api.public_bootstrap.cameras.values()):
+ async_dispatcher_send(self._hass, self.channels_signal, public)
@callback
def _async_process_public_updates(self) -> None:
@@ -284,7 +380,9 @@ class ProtectData:
if not api.has_public_bootstrap:
return
# The NVR alarm panel reads the public arm_mode, so refresh it too.
- self._async_signal_device_update(api.bootstrap.nvr)
+ # An API-key-only client has no private NVR (reading it would raise).
+ if not api.is_public_only:
+ self._async_signal_device_update(api.bootstrap.nvr)
# Subscribers recompute from the public bootstrap on ``None``.
for subscriptions in self._public_subscriptions.values():
for update_callback in subscriptions:
@@ -492,12 +590,12 @@ class ProtectData:
@callback
def async_subscribe_public_event(
self,
- mac: str,
+ device_id: str,
event_type: EventType,
update_callback: Callable[[ProtectEvent], None],
) -> CALLBACK_TYPE:
- """Add a callback subscriber for public events of a type by device mac."""
- key = (mac, event_type)
+ """Add a callback subscriber for public events of a type by device id."""
+ key = (device_id, event_type)
self._public_event_subscriptions[key].add(update_callback)
return partial(self._async_unsubscribe_public_event, key, update_callback)
@@ -531,7 +629,7 @@ class ProtectData:
@callback
def async_get_public_device(
- self, device: ProtectDeviceType
+ self, device: ProtectDeviceType | PublicDeviceModel
) -> PublicDeviceModel | None:
"""Return the public-API object matching a device, if available."""
api = self.api
diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py
index ebb74046d2cd..06b07cf51bf1 100644
--- a/homeassistant/components/unifiprotect/entity.py
+++ b/homeassistant/components/unifiprotect/entity.py
@@ -20,9 +20,11 @@ from uiprotect.data import (
SmartDetectObjectType,
StateType,
)
+from uiprotect.data.public_devices import PublicSensor, SensorFeatureCapability
-from homeassistant.core import callback
-from homeassistant.helpers import device_registry as dr
+from homeassistant.const import Platform
+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 import Entity, EntityDescription
@@ -48,6 +50,46 @@ class PermRequired(int, Enum):
DELETE = 3
+@callback
+def _async_capability_supported(
+ data: ProtectData,
+ device: ProtectAdoptableDeviceModel,
+ description: ProtectEntityDescription,
+) -> bool:
+ """Whether the device advertises the description's required sensor capability."""
+ if (capability := description.ufp_capability) is None:
+ return True
+ public = data.async_get_public_device(device)
+ if not isinstance(public, PublicSensor) or not public.has_feature_flags:
+ return True
+ return public.supports(capability)
+
+
+@callback
+def async_remove_unsupported_sense_entities(
+ hass: HomeAssistant,
+ platform: Platform,
+ data: ProtectData,
+ descs: Sequence[ProtectEntityDescription],
+) -> None:
+ """Remove registry entries for sense entities the device cannot support.
+
+ Only acts when a public capability map is present (newer firmware); a console
+ upgrade then drops the never-functional entities created before the map existed.
+ """
+ entity_registry = er.async_get(hass)
+ for device in data.get_by_types({ModelType.SENSOR}):
+ for description in descs:
+ if description.ufp_capability is None or _async_capability_supported(
+ data, device, description
+ ):
+ continue
+ if entity_id := entity_registry.async_get_entity_id(
+ platform, DOMAIN, f"{device.mac}_{description.key}"
+ ):
+ entity_registry.async_remove(entity_id)
+
+
@callback
def _async_device_entities(
data: ProtectData,
@@ -101,6 +143,9 @@ def _async_device_entities(
if not description.has_required(device):
continue
+ if not _async_capability_supported(data, device, description):
+ continue
+
entities.append(
klass(
data,
@@ -119,7 +164,6 @@ def _async_device_entities(
_ALL_MODEL_TYPES = (
- ModelType.AIPORT,
ModelType.CAMERA,
ModelType.LIGHT,
ModelType.SENSOR,
@@ -163,6 +207,10 @@ def async_all_device_entities(
device_model_type = ufp_device.model
assert device_model_type is not None
+ # Runtime adoption must honor the same model-type allowlist as initial setup,
+ # so unsupported devices (e.g. AI Port) get no entities when adopted live.
+ if device_model_type not in _ALL_MODEL_TYPES:
+ return []
descs = _combine_model_descs(device_model_type, model_descriptions, all_descs)
return _async_device_entities(
data, klass, device_model_type, descs, unadopted_descs, ufp_device
@@ -440,6 +488,10 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046
# Public counterpart of ``ufp_enabled``; a callable because public enablement
# is often compound (e.g. mount type plus a settings flag).
ufp_public_enabled_fn: Callable[[PublicDeviceModel], bool] | None = None
+ # Sensor capability required to create the entity, checked against the public
+ # capability map. Without a capability map (older firmware) every description
+ # is created, matching the pre-capability behavior.
+ ufp_capability: SensorFeatureCapability | None = None
ufp_perm: PermRequired | None = None
# The below are set in __post_init__
diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py
index 29d824de65fc..4875c4b9697f 100644
--- a/homeassistant/components/unifiprotect/event.py
+++ b/homeassistant/components/unifiprotect/event.py
@@ -86,7 +86,7 @@ class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventE
await super().async_added_to_hass()
self.async_on_remove(
self.data.async_subscribe_public_event(
- self.device.mac, EventType.RING, self._async_ring_event
+ self.device.id, EventType.RING, self._async_ring_event
)
)
@@ -382,7 +382,7 @@ class ProtectDeviceSmartDetectEventEntity(
await super().async_added_to_hass()
self.async_on_remove(
self.data.async_subscribe_public_event(
- self.device.mac, EventType.SMART_DETECT, self._async_smart_detect_event
+ self.device.id, EventType.SMART_DETECT, self._async_smart_detect_event
)
)
diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py
index 1dd90b65079d..e5e42b5bb65e 100644
--- a/homeassistant/components/unifiprotect/light.py
+++ b/homeassistant/components/unifiprotect/light.py
@@ -1,10 +1,11 @@
"""Component providing Lights for UniFi Protect."""
import logging
-from typing import Any, override
+from typing import Any, cast, override
from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel
from uiprotect.data.devices import LightDeviceSettings
+from uiprotect.data.public_devices import PublicLight
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
@@ -61,14 +62,29 @@ class ProtectLight(ProtectDeviceEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_state_attrs = ("_attr_available", "_attr_is_on", "_attr_brightness")
+ @override
+ async def async_added_to_hass(self) -> None:
+ """Read state from the public API (primed before the first update)."""
+ self._ufp_uses_public = True
+ self._ufp_public_obj = self.data.async_get_public_device(self.device)
+ self.async_on_remove(
+ self.data.async_subscribe_public(
+ self.device.mac, self._async_public_updated
+ )
+ )
+ await super().async_added_to_hass()
+
@callback
@override
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
super()._async_update_device_from_protect(device)
- updated_device = self.device
- self._attr_is_on = updated_device.is_light_on
- self._attr_brightness = unifi_brightness_to_hass(
- updated_device.light_device_settings.led_level
+ if (public := self._ufp_public_obj) is None:
+ return
+ light = cast(PublicLight, public)
+ self._attr_is_on = light.is_light_on
+ led_level = light.light_device_settings.led_level
+ self._attr_brightness = (
+ None if led_level is None else unifi_brightness_to_hass(led_level)
)
@async_ufp_instance_command
diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json
index 3161443e367a..5d2673998cef 100644
--- a/homeassistant/components/unifiprotect/manifest.json
+++ b/homeassistant/components/unifiprotect/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
- "requirements": ["uiprotect==15.4.0"]
+ "requirements": ["uiprotect==15.14.2"]
}
diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py
index 3e1804f6d19d..d2a94eb53b38 100644
--- a/homeassistant/components/unifiprotect/migrate.py
+++ b/homeassistant/components/unifiprotect/migrate.py
@@ -11,7 +11,11 @@ from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er, issue_registry as ir
+from homeassistant.helpers import (
+ device_registry as dr,
+ entity_registry as er,
+ issue_registry as ir,
+)
from homeassistant.helpers.issue_registry import IssueSeverity
from .const import DOMAIN
@@ -109,6 +113,10 @@ async def async_migrate_data(
async_deprecate_hdr(hass, entry)
_LOGGER.debug("Completed Migrate: async_deprecate_hdr")
+ _LOGGER.debug("Start Migrate: async_remove_aiport_devices")
+ async_remove_aiport_devices(hass, entry)
+ _LOGGER.debug("Completed Migrate: async_remove_aiport_devices")
+
_LOGGER.debug("Start Migrate: async_migrate_insecure_cameras")
async_migrate_insecure_cameras(hass, entry)
_LOGGER.debug("Completed Migrate: async_migrate_insecure_cameras")
@@ -118,6 +126,31 @@ async def async_migrate_data(
_LOGGER.debug("Completed Migrate: async_remove_package_binary_sensor")
+# Device type (``ProtectAdoptableDeviceModel.type``) reported by AI Ports. Matched
+# in the registry so cleanup does not depend on the bundled library still exposing
+# the AI Port model.
+_AIPORT_DEVICE_TYPE = "AI Port"
+
+
+@callback
+def async_remove_aiport_devices(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
+ """Remove AI Port devices and their diagnostic-only entities.
+
+ AI Ports only ever exposed diagnostic sensors (no automation-relevant
+ functionality) and behave transparently, extending the camera they back.
+ They have no public API representation, so support is dropped. Devices are
+ matched from the registry (by device type) rather than the live bootstrap, so
+ cleanup works even once the library drops the AI Port model.
+
+ Added in 2026.7.0
+ """
+ device_registry = dr.async_get(hass)
+ for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
+ if device.model_id != _AIPORT_DEVICE_TYPE:
+ continue
+ device_registry.async_remove_device(device.id)
+
+
@callback
def async_migrate_insecure_cameras(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
"""Migrate the legacy plain-RTSP "(insecure)" camera entities.
diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py
index e8ee0d0b09d8..2f7cf224e209 100644
--- a/homeassistant/components/unifiprotect/number.py
+++ b/homeassistant/components/unifiprotect/number.py
@@ -7,10 +7,14 @@ import logging
from typing import cast, override
from uiprotect.data import Camera, Chime, Light, ModelType, ProtectAdoptableDeviceModel
-from uiprotect.data.public_devices import PublicDeviceModel, PublicLight
+from uiprotect.data.public_devices import (
+ PublicDeviceModel,
+ PublicLight,
+ SensorFeatureCapability,
+)
from homeassistant.components.number import NumberEntity, NumberEntityDescription
-from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
+from homeassistant.const import PERCENTAGE, EntityCategory, Platform, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -22,6 +26,7 @@ from .entity import (
ProtectSettableKeysMixin,
T,
async_all_device_entities,
+ async_remove_unsupported_sense_entities,
)
from .utils import async_ufp_instance_command
@@ -85,8 +90,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
ufp_max=100,
ufp_step=1,
ufp_required_field="has_mic",
- ufp_value="mic_volume",
- ufp_enabled="feature_flags.has_mic",
+ ufp_public_value="mic_volume",
ufp_set_method="set_mic_volume_public",
ufp_perm=PermRequired.WRITE,
),
@@ -169,8 +173,8 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
ufp_min=0,
ufp_max=100,
ufp_step=1,
- ufp_value="light_device_settings.pir_sensitivity",
- ufp_set_method="set_sensitivity",
+ ufp_public_value="light_device_settings.pir_sensitivity",
+ ufp_set_method="set_sensitivity_public",
ufp_perm=PermRequired.WRITE,
),
ProtectNumberEntityDescription[Light](
@@ -196,8 +200,9 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
ufp_min=0,
ufp_max=100,
ufp_step=1,
- ufp_value="motion_settings.sensitivity",
- ufp_set_method="set_motion_sensitivity",
+ ufp_public_value="motion_settings.sensitivity",
+ ufp_set_method="set_motion_sensitivity_public",
+ ufp_capability=SensorFeatureCapability.MOTION,
ufp_perm=PermRequired.WRITE,
),
)
@@ -276,6 +281,7 @@ async def async_setup_entry(
) -> None:
"""Set up number entities for UniFi Protect integration."""
data = entry.runtime_data
+ async_remove_unsupported_sense_entities(hass, Platform.NUMBER, data, SENSE_NUMBERS)
@callback
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py
index fe9fa80f5073..888267d346a5 100644
--- a/homeassistant/components/unifiprotect/select.py
+++ b/homeassistant/components/unifiprotect/select.py
@@ -4,7 +4,7 @@ from collections.abc import Callable, Sequence
from dataclasses import dataclass
from enum import Enum
import logging
-from typing import Any, override
+from typing import Any, cast, override
from uiprotect.api import ProtectApiClient
from uiprotect.data import (
@@ -25,10 +25,15 @@ from uiprotect.data import (
Sensor,
Viewer,
)
+from uiprotect.data.public_devices import (
+ PublicCamera,
+ PublicDeviceModel,
+ SensorFeatureCapability,
+)
from uiprotect.exceptions import GlobalAlarmManagerError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
-from homeassistant.const import EntityCategory
+from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import EntityDescription
@@ -44,8 +49,9 @@ from .entity import (
ProtectSettableKeysMixin,
T,
async_all_device_entities,
+ async_remove_unsupported_sense_entities,
)
-from .utils import async_get_light_motion_current, async_ufp_instance_command
+from .utils import async_get_light_motion_current_public, async_ufp_instance_command
_LOGGER = logging.getLogger(__name__)
_KEY_LIGHT_MOTION = "light_motion"
@@ -167,7 +173,7 @@ def _get_doorbell_current(obj: Camera) -> str | None:
async def _set_light_mode(obj: Light, mode: str) -> None:
lightmode, timing = LIGHT_MODE_TO_SETTINGS[mode]
- await obj.set_light_settings(
+ await obj.set_light_mode_public(
LightModeType(lightmode),
enable_at=None if timing is None else LightModeEnableType(timing),
)
@@ -215,6 +221,16 @@ _HDR_MODE_MAP = {
"always": PublicHdrMode.ON,
"off": PublicHdrMode.OFF,
}
+_HDR_MODE_MAP_INVERSE = {v: k for k, v in _HDR_MODE_MAP.items()}
+
+
+def _get_hdr_mode_public(obj: PublicDeviceModel) -> str | None:
+ """Return the HDR option id from the public camera's ``hdr_type``.
+
+ ``hdr_type`` is non-optional on the public model; ``.get`` still yields
+ ``None`` for any value missing from the map.
+ """
+ return _HDR_MODE_MAP_INVERSE.get(cast(PublicCamera, obj).hdr_type)
async def _set_hdr_mode(obj: Camera, mode: str) -> None:
@@ -280,7 +296,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
ufp_required_field="feature_flags.has_hdr",
ufp_options=HDR_MODES,
- ufp_value="hdr_mode_display",
+ ufp_public_value_fn=_get_hdr_mode_public,
ufp_set_method_fn=_set_hdr_mode,
ufp_perm=PermRequired.WRITE,
),
@@ -292,7 +308,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = (
translation_key="light_mode",
entity_category=EntityCategory.CONFIG,
ufp_options=MOTION_MODE_TO_LIGHT_MODE,
- ufp_value_fn=async_get_light_motion_current,
+ ufp_public_value_fn=async_get_light_motion_current_public,
ufp_set_method_fn=_set_light_mode,
ufp_perm=PermRequired.WRITE,
),
@@ -316,6 +332,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = (
ufp_enum_type=MountType,
ufp_value="mount_type",
ufp_set_method="set_mount_type",
+ ufp_capability=SensorFeatureCapability.OPEN,
ufp_perm=PermRequired.WRITE,
),
ProtectSelectEntityDescription[Sensor](
@@ -355,6 +372,7 @@ async def async_setup_entry(
) -> None:
"""Set up number entities for UniFi Protect integration."""
data = entry.runtime_data
+ async_remove_unsupported_sense_entities(hass, Platform.SELECT, data, SENSE_SELECTS)
@callback
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py
index 127891a2ba18..cb3896cbd2c4 100644
--- a/homeassistant/components/unifiprotect/sensor.py
+++ b/homeassistant/components/unifiprotect/sensor.py
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from datetime import datetime
from functools import partial
import logging
-from typing import Any, override
+from typing import Any, cast, override
from uiprotect.data import (
NVR,
@@ -16,6 +16,12 @@ from uiprotect.data import (
ProtectDeviceModel,
Sensor,
)
+from uiprotect.data.public_devices import (
+ PublicDeviceModel,
+ PublicLight,
+ SensorFeatureCapability,
+)
+from uiprotect.utils import convert_to_datetime
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -28,6 +34,7 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
+ Platform,
UnitOfDataRate,
UnitOfElectricPotential,
UnitOfInformation,
@@ -48,8 +55,9 @@ from .entity import (
ProtectNVREntity,
T,
async_all_device_entities,
+ async_remove_unsupported_sense_entities,
)
-from .utils import async_get_light_motion_current
+from .utils import async_get_light_motion_current_public
_LOGGER = logging.getLogger(__name__)
OBJECT_TYPE_NONE = "none"
@@ -87,6 +95,11 @@ class ProtectSensorEventEntityDescription(
"""Describes UniFi Protect Sensor entity."""
+def _get_last_motion_public(obj: PublicDeviceModel) -> datetime | None:
+ # Public API reports last motion as a JS epoch (ms); private side a datetime.
+ return convert_to_datetime(cast(PublicLight, obj).last_motion)
+
+
def _get_uptime(obj: ProtectDeviceModel) -> datetime | None:
if obj.up_since is None:
return None
@@ -310,6 +323,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
ufp_value="stats.light.value",
ufp_enabled="is_light_sensor_enabled",
+ ufp_capability=SensorFeatureCapability.LIGHT,
),
ProtectSensorEntityDescription(
key="humidity_level",
@@ -318,6 +332,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
ufp_value="stats.humidity.value",
ufp_enabled="is_humidity_sensor_enabled",
+ ufp_capability=SensorFeatureCapability.HUMIDITY,
),
ProtectSensorEntityDescription(
key="temperature_level",
@@ -326,18 +341,21 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
ufp_value="stats.temperature.value",
ufp_enabled="is_temperature_sensor_enabled",
+ ufp_capability=SensorFeatureCapability.TEMPERATURE,
),
ProtectSensorEntityDescription[Sensor](
key="alarm_sound",
translation_key="alarm_sound_detected",
ufp_value_fn=_get_alarm_sound,
ufp_enabled="is_alarm_sensor_enabled",
+ ufp_capability=SensorFeatureCapability.SMOKE,
),
ProtectSensorEntityDescription(
key="door_last_trip_time",
translation_key="last_open",
device_class=SensorDeviceClass.TIMESTAMP,
ufp_value="open_status_changed_at",
+ ufp_capability=SensorFeatureCapability.OPEN,
entity_registry_enabled_default=False,
),
ProtectSensorEntityDescription(
@@ -345,11 +363,13 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
translation_key="last_motion_detected",
device_class=SensorDeviceClass.TIMESTAMP,
ufp_value="motion_detected_at",
+ ufp_capability=SensorFeatureCapability.MOTION,
entity_registry_enabled_default=False,
),
ProtectSensorEntityDescription(
key="tampering_last_trip_time",
translation_key="last_tampering_detected",
+ ufp_capability=SensorFeatureCapability.TAMPER,
device_class=SensorDeviceClass.TIMESTAMP,
ufp_value="tampering_detected_at",
entity_registry_enabled_default=False,
@@ -360,6 +380,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="motion_settings.sensitivity",
+ ufp_capability=SensorFeatureCapability.MOTION,
ufp_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription(
@@ -367,6 +388,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
translation_key="mount_type",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="mount_type",
+ ufp_capability=SensorFeatureCapability.OPEN,
ufp_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription(
@@ -496,7 +518,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key="motion_last_trip_time",
translation_key="last_motion_detected",
device_class=SensorDeviceClass.TIMESTAMP,
- ufp_value="last_motion",
+ ufp_public_value_fn=_get_last_motion_public,
entity_registry_enabled_default=False,
),
ProtectSensorEntityDescription(
@@ -504,14 +526,14 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
translation_key="motion_sensitivity",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
- ufp_value="light_device_settings.pir_sensitivity",
+ ufp_public_value="light_device_settings.pir_sensitivity",
ufp_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription[Light](
key="light_motion",
translation_key="light_mode",
entity_category=EntityCategory.DIAGNOSTIC,
- ufp_value_fn=async_get_light_motion_current,
+ ufp_public_value_fn=async_get_light_motion_current_public,
ufp_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription(
@@ -576,6 +598,7 @@ async def async_setup_entry(
) -> None:
"""Set up sensors for UniFi Protect integration."""
data = entry.runtime_data
+ async_remove_unsupported_sense_entities(hass, Platform.SENSOR, data, SENSE_SENSORS)
@callback
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json
index 80bbb142cdbf..eb7b65358f73 100644
--- a/homeassistant/components/unifiprotect/strings.json
+++ b/homeassistant/components/unifiprotect/strings.json
@@ -692,6 +692,9 @@
"global_alarm_manager": {
"message": "The alarm manager on this UniFi Protect NVR is set to Global mode and cannot be controlled locally."
},
+ "motion_detection_public_only": {
+ "message": "Motion detection cannot be changed over the public API; configure it in the UniFi Protect app"
+ },
"no_users_found": {
"message": "No users found, please check Protect permissions"
},
diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py
index f7f664c9d92c..54812f75882e 100644
--- a/homeassistant/components/unifiprotect/switch.py
+++ b/homeassistant/components/unifiprotect/switch.py
@@ -387,8 +387,8 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
key="status_light",
translation_key="status_light",
entity_category=EntityCategory.CONFIG,
- ufp_value="light_device_settings.is_indicator_enabled",
- ufp_set_method="set_status_light",
+ ufp_public_value="light_device_settings.is_indicator_enabled",
+ ufp_set_method="set_status_light_public",
ufp_perm=PermRequired.WRITE,
),
)
diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py
index 9e5f7af14c0b..fc411102f607 100644
--- a/homeassistant/components/unifiprotect/utils.py
+++ b/homeassistant/components/unifiprotect/utils.py
@@ -5,18 +5,18 @@ import contextlib
from functools import wraps
from pathlib import Path
import socket
-from typing import TYPE_CHECKING, Any, Concatenate
+from typing import TYPE_CHECKING, Any, Concatenate, cast
from aiohttp import CookieJar
from uiprotect import ProtectApiClient
from uiprotect.data import (
Bootstrap,
- CameraChannel,
- Light,
+ ChannelQuality,
LightModeEnableType,
LightModeType,
ProtectAdoptableDeviceModel,
)
+from uiprotect.data.public_devices import PublicDeviceModel, PublicLight
from uiprotect.exceptions import ClientError, NotAuthorized
from homeassistant.const import (
@@ -56,7 +56,7 @@ def _async_short_mac(mac: str) -> str:
return _async_unifi_mac_from_hass(mac)[-6:]
-async def _async_resolve(hass: HomeAssistant, host: str) -> str | None:
+async def _async_resolve(hass: HomeAssistant, host: str) -> str | int | None:
"""Resolve a hostname to an ip."""
with contextlib.suppress(OSError):
return next(
@@ -95,15 +95,14 @@ def async_get_devices(
@callback
-def async_get_light_motion_current(obj: Light) -> str:
- """Get light motion mode for Flood Light."""
-
- if (
- obj.light_mode_settings.mode is LightModeType.MOTION
- and obj.light_mode_settings.enable_at is LightModeEnableType.DARK
- ):
+def async_get_light_motion_current_public(obj: PublicDeviceModel) -> str | None:
+ """Get light motion mode for a Flood Light from the public API."""
+ settings = cast(PublicLight, obj).light_mode_settings
+ if (mode := settings.mode) is None:
+ return None
+ if mode is LightModeType.MOTION and settings.enable_at is LightModeEnableType.DARK:
return f"{LightModeType.MOTION.value}_dark"
- return obj.light_mode_settings.mode.value
+ return mode.value
@callback
@@ -134,14 +133,12 @@ def async_create_api_client(
@callback
-def get_camera_base_name(channel: CameraChannel) -> str:
- """Get base name for cameras channel."""
+def get_camera_base_name(quality: ChannelQuality) -> str:
+ """Get base name for a camera's RTSPS quality channel."""
- camera_name = channel.name
- if channel.name != "Package Camera":
- camera_name = f"{channel.name} resolution channel"
-
- return camera_name
+ if quality is ChannelQuality.PACKAGE:
+ return "Package Camera"
+ return f"{quality.value.title()} resolution channel"
def async_ufp_instance_command[_EntityT, **_P](
diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py
index 1b84bbfb7cfd..71ffe80206f8 100644
--- a/homeassistant/components/universal/media_player.py
+++ b/homeassistant/components/universal/media_player.py
@@ -6,28 +6,13 @@ from typing import Any, override
import voluptuous as vol
from homeassistant.components.media_player import (
- ATTR_APP_ID,
- ATTR_APP_NAME,
ATTR_INPUT_SOURCE,
ATTR_INPUT_SOURCE_LIST,
- ATTR_MEDIA_ALBUM_ARTIST,
- ATTR_MEDIA_ALBUM_NAME,
- ATTR_MEDIA_ARTIST,
- ATTR_MEDIA_CHANNEL,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
- ATTR_MEDIA_DURATION,
- ATTR_MEDIA_EPISODE,
- ATTR_MEDIA_PLAYLIST,
- ATTR_MEDIA_POSITION,
- ATTR_MEDIA_POSITION_UPDATED_AT,
ATTR_MEDIA_REPEAT,
- ATTR_MEDIA_SEASON,
ATTR_MEDIA_SEEK_POSITION,
- ATTR_MEDIA_SERIES_TITLE,
ATTR_MEDIA_SHUFFLE,
- ATTR_MEDIA_TITLE,
- ATTR_MEDIA_TRACK,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
ATTR_SOUND_MODE,
@@ -41,16 +26,15 @@ from homeassistant.components.media_player import (
SERVICE_SELECT_SOURCE,
BrowseMedia,
MediaPlayerEntity,
+ MediaPlayerEntityCapabilityAttribute,
MediaPlayerEntityFeature,
+ MediaPlayerEntityStateAttribute,
MediaPlayerState,
MediaType,
RepeatMode,
)
from homeassistant.const import (
- ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
- ATTR_ENTITY_PICTURE,
- ATTR_SUPPORTED_FEATURES,
CONF_DEVICE_CLASS,
CONF_NAME,
CONF_STATE,
@@ -76,6 +60,7 @@ from homeassistant.const import (
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
Platform,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
@@ -318,7 +303,7 @@ class UniversalMediaPlayer(MediaPlayerEntity):
@override
def assumed_state(self) -> bool:
"""Return True if unable to access real state of the entity."""
- return self._child_attr(ATTR_ASSUMED_STATE)
+ return self._child_attr(EntityStateAttribute.ASSUMED_STATE)
@property
@override
@@ -343,7 +328,11 @@ class UniversalMediaPlayer(MediaPlayerEntity):
def volume_level(self):
"""Volume level of entity specified in attributes or active child."""
try:
- return float(self._override_or_child_attr(ATTR_MEDIA_VOLUME_LEVEL))
+ return float(
+ self._override_or_child_attr(
+ MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL
+ )
+ )
except TypeError, ValueError:
return None
@@ -351,31 +340,33 @@ class UniversalMediaPlayer(MediaPlayerEntity):
@override
def is_volume_muted(self):
"""Boolean if volume is muted."""
- return self._override_or_child_attr(ATTR_MEDIA_VOLUME_MUTED) in [True, STATE_ON]
+ return self._override_or_child_attr(
+ MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED
+ ) in [True, STATE_ON]
@property
@override
def media_content_id(self):
"""Return the content ID of current playing media."""
- return self._child_attr(ATTR_MEDIA_CONTENT_ID)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID)
@property
@override
def media_content_type(self):
"""Return the content type of current playing media."""
- return self._child_attr(ATTR_MEDIA_CONTENT_TYPE)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE)
@property
@override
def media_duration(self):
"""Return the duration of current playing media in seconds."""
- return self._child_attr(ATTR_MEDIA_DURATION)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_DURATION)
@property
@override
def media_image_url(self):
"""Image url of current playing media."""
- return self._override_or_child_attr(ATTR_ENTITY_PICTURE)
+ return self._override_or_child_attr(EntityStateAttribute.ENTITY_PICTURE)
@property
@override
@@ -392,116 +383,126 @@ class UniversalMediaPlayer(MediaPlayerEntity):
@override
def media_title(self):
"""Title of current playing media."""
- return self._child_attr(ATTR_MEDIA_TITLE)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_TITLE)
@property
@override
def media_artist(self):
"""Artist of current playing media (Music track only)."""
- return self._child_attr(ATTR_MEDIA_ARTIST)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ARTIST)
@property
@override
def media_album_name(self):
"""Album name of current playing media (Music track only)."""
- return self._child_attr(ATTR_MEDIA_ALBUM_NAME)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ALBUM_NAME)
@property
@override
def media_album_artist(self):
"""Album artist of current playing media (Music track only)."""
- return self._child_attr(ATTR_MEDIA_ALBUM_ARTIST)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ALBUM_ARTIST)
@property
@override
def media_track(self):
"""Track number of current playing media (Music track only)."""
- return self._child_attr(ATTR_MEDIA_TRACK)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_TRACK)
@property
@override
def media_series_title(self):
"""Return the title of the series of current playing media (TV)."""
- return self._child_attr(ATTR_MEDIA_SERIES_TITLE)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_SERIES_TITLE)
@property
@override
def media_season(self):
"""Season of current playing media (TV Show only)."""
- return self._child_attr(ATTR_MEDIA_SEASON)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_SEASON)
@property
@override
def media_episode(self):
"""Episode of current playing media (TV Show only)."""
- return self._child_attr(ATTR_MEDIA_EPISODE)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_EPISODE)
@property
@override
def media_channel(self):
"""Channel currently playing."""
- return self._child_attr(ATTR_MEDIA_CHANNEL)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CHANNEL)
@property
@override
def media_playlist(self):
"""Title of Playlist currently playing."""
- return self._child_attr(ATTR_MEDIA_PLAYLIST)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_PLAYLIST)
@property
@override
def app_id(self):
"""ID of the current running app."""
- return self._child_attr(ATTR_APP_ID)
+ return self._child_attr(MediaPlayerEntityStateAttribute.APP_ID)
@property
@override
def app_name(self):
"""Name of the current running app."""
- return self._child_attr(ATTR_APP_NAME)
+ return self._child_attr(MediaPlayerEntityStateAttribute.APP_NAME)
@property
@override
def sound_mode(self):
"""Return the current sound mode of the device."""
- return self._override_or_child_attr(ATTR_SOUND_MODE)
+ return self._override_or_child_attr(MediaPlayerEntityStateAttribute.SOUND_MODE)
@property
@override
def sound_mode_list(self):
"""List of available sound modes."""
- return self._override_or_child_attr(ATTR_SOUND_MODE_LIST)
+ return self._override_or_child_attr(
+ MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST
+ )
@property
@override
def source(self):
"""Return the current input source of the device."""
- return self._override_or_child_attr(ATTR_INPUT_SOURCE)
+ return self._override_or_child_attr(
+ MediaPlayerEntityStateAttribute.INPUT_SOURCE
+ )
@property
@override
def source_list(self):
"""List of available input sources."""
- return self._override_or_child_attr(ATTR_INPUT_SOURCE_LIST)
+ return self._override_or_child_attr(
+ MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST
+ )
@property
@override
def repeat(self):
"""Boolean if repeating is enabled."""
- return self._override_or_child_attr(ATTR_MEDIA_REPEAT)
+ return self._override_or_child_attr(
+ MediaPlayerEntityStateAttribute.MEDIA_REPEAT
+ )
@property
@override
def shuffle(self):
"""Boolean if shuffling is enabled."""
- return self._override_or_child_attr(ATTR_MEDIA_SHUFFLE)
+ return self._override_or_child_attr(
+ MediaPlayerEntityStateAttribute.MEDIA_SHUFFLE
+ )
@property
@override
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag media player features that are supported."""
flags: MediaPlayerEntityFeature = self._child_attr(
- ATTR_SUPPORTED_FEATURES
+ EntityStateAttribute.SUPPORTED_FEATURES
) or MediaPlayerEntityFeature(0)
if SERVICE_TURN_ON in self._cmds:
@@ -573,13 +574,15 @@ class UniversalMediaPlayer(MediaPlayerEntity):
@override
def media_position(self):
"""Position of current playing media in seconds."""
- return self._child_attr(ATTR_MEDIA_POSITION)
+ return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_POSITION)
@property
@override
def media_position_updated_at(self):
"""When was the position of the current playing media valid."""
- return self._child_attr(ATTR_MEDIA_POSITION_UPDATED_AT)
+ return self._child_attr(
+ MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT
+ )
@override
async def async_turn_on(self) -> None:
diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json
index 8eb8e99fd355..dc44e595d988 100644
--- a/homeassistant/components/upb/manifest.json
+++ b/homeassistant/components/upb/manifest.json
@@ -3,6 +3,7 @@
"name": "Universal Powerline Bus (UPB)",
"codeowners": ["@gwww"],
"config_flow": true,
+ "dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/upb",
"iot_class": "local_push",
"loggers": ["upb_lib"],
diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py
index d85675d8a4dc..2b9604c42dd0 100644
--- a/homeassistant/components/upnp/const.py
+++ b/homeassistant/components/upnp/const.py
@@ -15,8 +15,12 @@ PACKETS_RECEIVED = "packets_received"
PACKETS_SENT = "packets_sent"
KIBIBYTES_PER_SEC_RECEIVED = "kibibytes_per_sec_received"
KIBIBYTES_PER_SEC_SENT = "kibibytes_per_sec_sent"
+KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER = "kibibytes_per_sec_received_no_rollover"
+KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER = "kibibytes_per_sec_sent_no_rollover"
PACKETS_PER_SEC_RECEIVED = "packets_per_sec_received"
PACKETS_PER_SEC_SENT = "packets_per_sec_sent"
+PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER = "packets_per_sec_received_no_rollover"
+PACKETS_PER_SEC_SENT_NO_ROLLOVER = "packets_per_sec_sent_no_rollover"
TIMESTAMP = "timestamp"
DATA_PACKETS = "packets"
DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{UnitOfTime.SECONDS}"
diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py
index e3d9690afb89..a93a7a61f981 100644
--- a/homeassistant/components/upnp/device.py
+++ b/homeassistant/components/upnp/device.py
@@ -22,10 +22,14 @@ from .const import (
BYTES_RECEIVED,
BYTES_SENT,
KIBIBYTES_PER_SEC_RECEIVED,
+ KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER,
KIBIBYTES_PER_SEC_SENT,
+ KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER,
LOGGER as _LOGGER,
PACKETS_PER_SEC_RECEIVED,
+ PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER,
PACKETS_PER_SEC_SENT,
+ PACKETS_PER_SEC_SENT_NO_ROLLOVER,
PACKETS_RECEIVED,
PACKETS_SENT,
PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4,
@@ -256,8 +260,12 @@ class Device:
ROUTER_IP: get_value(igd_state.external_ip_address),
KIBIBYTES_PER_SEC_RECEIVED: igd_state.kibibytes_per_sec_received,
KIBIBYTES_PER_SEC_SENT: igd_state.kibibytes_per_sec_sent,
+ KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER: igd_state.kibibytes_per_sec_received_no_rollover,
+ KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER: igd_state.kibibytes_per_sec_sent_no_rollover,
PACKETS_PER_SEC_RECEIVED: igd_state.packets_per_sec_received,
PACKETS_PER_SEC_SENT: igd_state.packets_per_sec_sent,
+ PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER: igd_state.packets_per_sec_received_no_rollover,
+ PACKETS_PER_SEC_SENT_NO_ROLLOVER: igd_state.packets_per_sec_sent_no_rollover,
PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4: get_value(
igd_state.port_mapping_number_of_entries
),
diff --git a/homeassistant/components/upnp/icons.json b/homeassistant/components/upnp/icons.json
index 8f6b1c493662..ad6abd63ccfa 100644
--- a/homeassistant/components/upnp/icons.json
+++ b/homeassistant/components/upnp/icons.json
@@ -7,9 +7,15 @@
"packet_download_speed": {
"default": "mdi:transmission-tower"
},
+ "packet_download_speed_no_rollover_handling": {
+ "default": "mdi:transmission-tower"
+ },
"packet_upload_speed": {
"default": "mdi:transmission-tower"
},
+ "packet_upload_speed_no_rollover_handling": {
+ "default": "mdi:transmission-tower"
+ },
"packets_received": {
"default": "mdi:database"
},
diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json
index f1d6a3a18fd5..251a2a9f2c32 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.46.2", "getmac==0.9.5"],
+ "requirements": ["async-upnp-client==0.47.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 be3c25455bd3..379e4b033c6c 100644
--- a/homeassistant/components/upnp/sensor.py
+++ b/homeassistant/components/upnp/sensor.py
@@ -25,10 +25,14 @@ from .const import (
DATA_PACKETS,
DATA_RATE_PACKETS_PER_SECOND,
KIBIBYTES_PER_SEC_RECEIVED,
+ KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER,
KIBIBYTES_PER_SEC_SENT,
+ KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER,
LOGGER,
PACKETS_PER_SEC_RECEIVED,
+ PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER,
PACKETS_PER_SEC_SENT,
+ PACKETS_PER_SEC_SENT_NO_ROLLOVER,
PACKETS_RECEIVED,
PACKETS_SENT,
PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4,
@@ -126,6 +130,28 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
+ UpnpSensorEntityDescription(
+ key=BYTES_RECEIVED,
+ translation_key="download_speed_no_rollover_handling",
+ value_key=KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER,
+ unique_id="KiB/sec_received_no_rollover",
+ device_class=SensorDeviceClass.DATA_RATE,
+ native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ entity_registry_enabled_default=False,
+ ),
+ UpnpSensorEntityDescription(
+ key=BYTES_SENT,
+ translation_key="upload_speed_no_rollover_handling",
+ value_key=KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER,
+ unique_id="KiB/sec_sent_no_rollover",
+ device_class=SensorDeviceClass.DATA_RATE,
+ native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ entity_registry_enabled_default=False,
+ ),
UpnpSensorEntityDescription(
key=PACKETS_RECEIVED,
translation_key="packet_download_speed",
@@ -146,6 +172,26 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
+ UpnpSensorEntityDescription(
+ key=PACKETS_RECEIVED,
+ translation_key="packet_download_speed_no_rollover_handling",
+ value_key=PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER,
+ unique_id="packets/sec_received_no_rollover",
+ native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ ),
+ UpnpSensorEntityDescription(
+ key=PACKETS_SENT,
+ translation_key="packet_upload_speed_no_rollover_handling",
+ value_key=PACKETS_PER_SEC_SENT_NO_ROLLOVER,
+ unique_id="packets/sec_sent_no_rollover",
+ native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ ),
)
diff --git a/homeassistant/components/upnp/strings.json b/homeassistant/components/upnp/strings.json
index a62c8c4af080..79464d3bc57d 100644
--- a/homeassistant/components/upnp/strings.json
+++ b/homeassistant/components/upnp/strings.json
@@ -33,15 +33,24 @@
"download_speed": {
"name": "Download speed"
},
+ "download_speed_no_rollover_handling": {
+ "name": "Download speed (no rollover handling)"
+ },
"external_ip": {
"name": "External IP"
},
"packet_download_speed": {
"name": "Packet download speed"
},
+ "packet_download_speed_no_rollover_handling": {
+ "name": "Packet download speed (no rollover handling)"
+ },
"packet_upload_speed": {
"name": "Packet upload speed"
},
+ "packet_upload_speed_no_rollover_handling": {
+ "name": "Packet upload speed (no rollover handling)"
+ },
"packets_received": {
"name": "Packets received"
},
@@ -54,6 +63,9 @@
"upload_speed": {
"name": "Upload speed"
},
+ "upload_speed_no_rollover_handling": {
+ "name": "Upload speed (no rollover handling)"
+ },
"uptime": {
"name": "Uptime"
},
diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py
index a0e2c77341c6..8fb244b18df8 100644
--- a/homeassistant/components/utility_meter/__init__.py
+++ b/homeassistant/components/utility_meter/__init__.py
@@ -205,7 +205,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
- add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json
index ddad80b92f24..903280052d00 100644
--- a/homeassistant/components/v2c/manifest.json
+++ b/homeassistant/components/v2c/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/v2c",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["pytrydan==1.0.3"]
+ "requirements": ["pytrydan==1.0.4"]
}
diff --git a/homeassistant/components/vacuum/llm.py b/homeassistant/components/vacuum/llm.py
new file mode 100644
index 000000000000..eb28c4a9ca25
--- /dev/null
+++ b/homeassistant/components/vacuum/llm.py
@@ -0,0 +1,46 @@
+"""LLM tools for the vacuum integration."""
+
+from homeassistant.components.homeassistant import async_should_expose
+from homeassistant.components.llm import LLMTools
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import intent
+from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
+
+from .const import DOMAIN
+from .intent import (
+ INTENT_VACUUM_CLEAN_AREA,
+ INTENT_VACUUM_RETURN_TO_BASE,
+ INTENT_VACUUM_START,
+)
+
+# Intents owned by this integration that are exposed as LLM tools.
+LLM_INTENTS = (
+ INTENT_VACUUM_CLEAN_AREA,
+ INTENT_VACUUM_RETURN_TO_BASE,
+ INTENT_VACUUM_START,
+)
+
+
+@callback
+def async_get_tools(
+ hass: HomeAssistant, llm_context: LLMContext, api_id: str
+) -> LLMTools | None:
+ """Return LLM tools for the integration's intents when its domain is exposed."""
+ if api_id != LLM_API_ASSIST:
+ return None
+
+ if not llm_context.assistant:
+ return None
+
+ if not any(
+ async_should_expose(hass, llm_context.assistant, state.entity_id)
+ for state in hass.states.async_all(DOMAIN)
+ ):
+ return None
+
+ tools: list[Tool] = [
+ IntentTool(handler.intent_type, handler)
+ for handler in intent.async_get(hass)
+ if handler.intent_type in LLM_INTENTS
+ ]
+ return LLMTools(tools=tools)
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
index 4e8f0f0bfc4f..5cc742925995 100644
--- a/homeassistant/components/velbus/__init__.py
+++ b/homeassistant/components/velbus/__init__.py
@@ -138,22 +138,18 @@ async def async_remove_config_entry_device(
config_entry: VelbusConfigEntry,
device_entry: dr.DeviceEntry,
) -> bool:
- """Allow removing a Velbus device and detach its sub-devices.
+ """Allow removing a Velbus device and its sub-devices.
- Sub-devices are detached from this config entry when their parent is
- removed. If the device is still on the bus, it may be recreated when
- the integration is reloaded or started again.
+ Sub-devices are removed along with their parent. If the device is still
+ on the bus, it may be recreated when the integration is reloaded or
+ started again.
"""
if config_entry.entry_id not in device_entry.config_entries:
return False
dev_reg = dr.async_get(hass)
for sub_device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id):
if sub_device.via_device_id == device_entry.id:
- dev_reg.async_update_device(
- sub_device.id,
- remove_config_entry_id=config_entry.entry_id,
- via_device_id=None,
- )
+ dev_reg.async_remove_device(sub_device.id)
return True
diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json
index b01c5bb48e17..99bd7149d750 100644
--- a/homeassistant/components/velbus/manifest.json
+++ b/homeassistant/components/velbus/manifest.json
@@ -11,7 +11,8 @@
"velbus-parser",
"velbus-module",
"velbus-packet",
- "velbus-protocol"
+ "velbus-protocol",
+ "velbus-handler"
],
"quality_scale": "silver",
"requirements": ["velbus-aio==2026.4.1"],
diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py
index 3adce293632b..2125663e4bf0 100644
--- a/homeassistant/components/velbus/sensor.py
+++ b/homeassistant/components/velbus/sensor.py
@@ -40,6 +40,7 @@ SENSOR_DESCRIPTIONS: dict[str, VelbusSensorEntityDescription] = {
key="power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda channel: float(channel.get_counter_state()),
unit_fn=lambda channel: channel.get_unit(),
),
"temperature": VelbusSensorEntityDescription(
@@ -58,7 +59,11 @@ SENSOR_DESCRIPTIONS: dict[str, VelbusSensorEntityDescription] = {
device_class=SensorDeviceClass.ENERGY,
icon="mdi:counter",
state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda channel: float(channel.get_counter_state()),
+ value_fn=lambda channel: (
+ float(channel.energy)
+ if hasattr(channel, "energy") and channel.energy is not None
+ else None
+ ),
unit_fn=lambda channel: channel.get_counter_unit(),
unique_id_suffix="-counter",
),
diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py
index b650e1fe46c4..3fd87f7a52a8 100644
--- a/homeassistant/components/velux/__init__.py
+++ b/homeassistant/components/velux/__init__.py
@@ -2,7 +2,7 @@
import dataclasses
-from pyvlx import PyVLX, PyVLXException, Window
+from pyvlx import OpeningDevice, PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -75,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
limitation_coordinators: dict[int, VeluxLimitationCoordinator] = {}
for node in pyvlx.nodes:
- if isinstance(node, Window) and node.rain_sensor:
+ if isinstance(node, OpeningDevice):
coordinator = VeluxLimitationCoordinator(hass, entry, node)
# do not await coordinator.async_config_entry_first_refresh() here to avoid doing
# it for disabled entities, the entities will call it when they are added to hass
diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py
index 70517826880c..6216e75aa970 100644
--- a/homeassistant/components/velux/binary_sensor.py
+++ b/homeassistant/components/velux/binary_sensor.py
@@ -60,7 +60,8 @@ class VeluxRainSensor(
"""Called when the entity is added to Home Assistant."""
await super().async_added_to_hass()
# Get initial state as we didn't do it on coordinator initialization to avoid doing it for disabled entities
- await self.coordinator.async_request_refresh()
+ if self.coordinator.data is None:
+ await self.coordinator.async_request_refresh()
@property
@override
@@ -69,8 +70,8 @@ class VeluxRainSensor(
# Velux windows with rain sensors report an opening
# limitation when rain is detected. So far we've
# seen 89, 91, 93 (most cases) or 100 (Velux GPU).
- # It probably makes sense to
- # assume that any large enough limitation (we use >=89) means rain is detected.
+ # It probably makes sense to assume that any large
+ # enough limitation (we use >=89) means rain is detected.
# Documentation on this is non-existent AFAIK.
if self.coordinator.data is None:
return None
diff --git a/homeassistant/components/velux/coordinator.py b/homeassistant/components/velux/coordinator.py
index b49b813c1555..7ad9fd2829f0 100644
--- a/homeassistant/components/velux/coordinator.py
+++ b/homeassistant/components/velux/coordinator.py
@@ -21,6 +21,7 @@ class VeluxLimitationData:
"""Data for one opening device's limitations."""
limitation_min: Position
+ limitation_max: Position
class VeluxLimitationCoordinator(DataUpdateCoordinator[VeluxLimitationData | None]):
@@ -44,9 +45,16 @@ class VeluxLimitationCoordinator(DataUpdateCoordinator[VeluxLimitationData | Non
@override
async def _async_update_data(self) -> VeluxLimitationData:
- """Fetch limitation min data from the device."""
+ """Fetch limitation min and max from the device."""
try:
min_pos = await self.node.get_limitation_min()
+ max_pos = await self.node.get_limitation_max()
+ LOGGER.debug(
+ "Fetched limitations for %s: pyvlx_min=%s%% pyvlx_max=%s%%",
+ self.node.name,
+ min_pos.position_percent,
+ max_pos.position_percent,
+ )
except (OSError, PyVLXException) as err:
raise UpdateFailed(f"Error fetching limitations: {err}") from err
- return VeluxLimitationData(limitation_min=min_pos)
+ return VeluxLimitationData(limitation_min=min_pos, limitation_max=max_pos)
diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py
index 412ae43520a6..0959d22a83a4 100644
--- a/homeassistant/components/velux/entity.py
+++ b/homeassistant/components/velux/entity.py
@@ -68,7 +68,6 @@ class VeluxEntity(Entity):
def __init__(self, node: Node, config_entry_id: str) -> None:
"""Initialize the Velux device."""
self.node = node
-
self._attr_unique_id = velux_unique_id(node, config_entry_id)
self._attr_device_info = velux_device_info(node, config_entry_id)
diff --git a/homeassistant/components/velux/number.py b/homeassistant/components/velux/number.py
index 9e24878027be..e026ef004883 100644
--- a/homeassistant/components/velux/number.py
+++ b/homeassistant/components/velux/number.py
@@ -1,16 +1,24 @@
-"""Support for Velux exterior heating number entities."""
+"""Support for Velux exterior heating and cover open/closed number entities."""
+from dataclasses import replace
from typing import override
-from pyvlx import ExteriorHeating, Intensity
+from pyvlx import ExteriorHeating, Intensity, OpeningDevice, Position
-from homeassistant.components.number import NumberEntity
-from homeassistant.const import PERCENTAGE
+from homeassistant.components.number import NumberEntity, NumberMode
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfRatio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import VeluxConfigEntry
-from .entity import VeluxEntity, wrap_pyvlx_call_exceptions
+from .coordinator import VeluxLimitationCoordinator
+from .entity import (
+ VeluxEntity,
+ velux_device_info,
+ velux_unique_id,
+ wrap_pyvlx_call_exceptions,
+)
PARALLEL_UPDATES = 1
@@ -22,11 +30,22 @@ async def async_setup_entry(
) -> None:
"""Set up number entities for the Velux platform."""
pyvlx = config_entry.runtime_data.pyvlx
- async_add_entities(
+ limitation_coordinators = config_entry.runtime_data.limitation_coordinators
+ entities: list[NumberEntity] = [
VeluxExteriorHeatingNumber(node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, ExteriorHeating)
- )
+ ]
+ for node in pyvlx.nodes:
+ if isinstance(node, OpeningDevice):
+ coordinator = limitation_coordinators[node.node_id]
+ entities.extend(
+ [
+ VeluxOpenPositionLimitNumber(coordinator, config_entry.entry_id),
+ VeluxClosedPositionLimitNumber(coordinator, config_entry.entry_id),
+ ]
+ )
+ async_add_entities(entities)
class VeluxExteriorHeatingNumber(VeluxEntity, NumberEntity):
@@ -56,3 +75,170 @@ class VeluxExteriorHeatingNumber(VeluxEntity, NumberEntity):
Intensity(intensity_percent=round(value)),
wait_for_completion=True,
)
+
+
+class VeluxPositionLimitNumber(
+ CoordinatorEntity[VeluxLimitationCoordinator], NumberEntity
+):
+ """Shared behavior for Velux limitation number entities.
+
+ Home Assistant expresses cover position as opening percentage, while pyvlx
+ uses the opposite direction. These entities expose HA-side open/closed
+ position limits and convert to pyvlx positions only at the API boundary.
+ """
+
+ _attr_entity_category = EntityCategory.CONFIG
+ _attr_entity_registry_enabled_default = False
+ _attr_mode = NumberMode.BOX
+ _attr_native_step = 1
+ _attr_native_unit_of_measurement = UnitOfRatio.PERCENTAGE
+ _attr_has_entity_name = True
+
+ _limitation_kind: str
+
+ def __init__(
+ self, coordinator: VeluxLimitationCoordinator, config_entry_id: str
+ ) -> None:
+ """Initialize Velux limitation number."""
+ super().__init__(coordinator)
+ node = coordinator.node
+ unique_id = velux_unique_id(node, config_entry_id)
+ self._attr_unique_id = f"{unique_id}_{self._limitation_kind}_limitation"
+ self._attr_translation_key = f"{self._limitation_kind}_position_limitation"
+ self._attr_device_info = velux_device_info(node, config_entry_id)
+
+ @override
+ async def async_added_to_hass(self) -> None:
+ """Request an immediate refresh when the entity is first added."""
+ await super().async_added_to_hass()
+ # Get initial state as we didn't do it on coordinator initialization to avoid doing it for disabled entities
+ if self.coordinator.data is None:
+ await self.coordinator.async_request_refresh()
+
+ @property
+ @override
+ def available(self) -> bool:
+ """Return False until coordinator has successfully populated data.
+
+ The entity is only available once the coordinator has successfully
+ fetched data at least once.
+ """
+ if self.coordinator.data is None:
+ return False
+ return super().available
+
+ @property
+ @override
+ def native_value(self) -> float | None:
+ """Return the current limitation in Home Assistant semantics."""
+ if position := self._get_pyvlx_limit():
+ return 100 - position.position_percent
+ return None
+
+ @wrap_pyvlx_call_exceptions
+ @override
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the limitation in Home Assistant semantics."""
+ # this will only be called if the entity is available, so coordinator.data is not None
+
+ await self._async_set_pyvlx_limitation(
+ Position(position_percent=100 - round(value))
+ )
+
+ def _get_pyvlx_limit(self) -> Position | None:
+ """Get the pyvlx limitation backing this HA-side entity."""
+ raise NotImplementedError
+
+ def _updated_pyvlx_limits(
+ self, updated_position: Position, current_min: Position, current_max: Position
+ ) -> tuple[Position, Position]:
+ """Return pyvlx min/max values with this entity's side updated."""
+ raise NotImplementedError
+
+ async def _async_set_pyvlx_limitation(self, position: Position) -> None:
+ """Set pyvlx limitations while preserving the unchanged side."""
+ assert self.coordinator.data is not None # checked in async_set_native_value
+ current_min = self.coordinator.data.limitation_min
+ current_max = self.coordinator.data.limitation_max
+ position_min, position_max = self._updated_pyvlx_limits(
+ position, current_min, current_max
+ )
+ await self.coordinator.node.set_position_limitations(
+ position_min=position_min,
+ position_max=position_max,
+ )
+ self.coordinator.async_set_updated_data(
+ replace(
+ self.coordinator.data,
+ limitation_min=position_min,
+ limitation_max=position_max,
+ )
+ )
+
+
+class VeluxClosedPositionLimitNumber(VeluxPositionLimitNumber):
+ """Representation of the closed position limit."""
+
+ _attr_native_min_value = 0
+ _limitation_kind = "closed"
+
+ def _sibling_value(self) -> float | None:
+ """Return the sibling open limit value, or None if unknown."""
+ return (
+ 100 - self.coordinator.data.limitation_min.position_percent
+ if self.coordinator.data
+ else None
+ )
+
+ @property
+ @override
+ def native_max_value(self) -> float:
+ """Return the upper bound: the current open limit (or 100 if unknown)."""
+ sibling_value = self._sibling_value()
+ return sibling_value if sibling_value is not None else 100
+
+ @override
+ def _get_pyvlx_limit(self) -> Position | None:
+ """Get the pyvlx max limit backing the HA closed position limit."""
+ return self.coordinator.data.limitation_max if self.coordinator.data else None
+
+ @override
+ def _updated_pyvlx_limits(
+ self, updated_position: Position, current_min: Position, current_max: Position
+ ) -> tuple[Position, Position]:
+ """Update pyvlx max and preserve pyvlx min for HA closed limit changes."""
+ return current_min, updated_position
+
+
+class VeluxOpenPositionLimitNumber(VeluxPositionLimitNumber):
+ """Representation of the open position limit."""
+
+ _attr_native_max_value = 100
+ _limitation_kind = "open"
+
+ def _sibling_value(self) -> float | None:
+ """Return the sibling close limit value, or None if unknown."""
+ return (
+ 100 - self.coordinator.data.limitation_max.position_percent
+ if self.coordinator.data
+ else None
+ )
+
+ @property
+ @override
+ def native_min_value(self) -> float:
+ """Return the lower bound: the current closed limit (or 0 if unknown)."""
+ sibling_value = self._sibling_value()
+ return sibling_value if sibling_value is not None else 0
+
+ @override
+ def _get_pyvlx_limit(self) -> Position | None:
+ """Get the pyvlx min limit backing the HA open position limit."""
+ return self.coordinator.data.limitation_min if self.coordinator.data else None
+
+ @override
+ def _updated_pyvlx_limits(
+ self, updated_position: Position, current_min: Position, current_max: Position
+ ) -> tuple[Position, Position]:
+ """Update pyvlx min and preserve pyvlx max for HA open limit changes."""
+ return updated_position, current_max
diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json
index f833503aaac9..9a7484f820ee 100644
--- a/homeassistant/components/velux/strings.json
+++ b/homeassistant/components/velux/strings.json
@@ -53,6 +53,14 @@
"dual_roller_shutter_upper": {
"name": "Upper shutter"
}
+ },
+ "number": {
+ "closed_position_limitation": {
+ "name": "Closed position limit"
+ },
+ "open_position_limitation": {
+ "name": "Open position limit"
+ }
}
},
"exceptions": {
diff --git a/homeassistant/components/venstar/entity.py b/homeassistant/components/venstar/entity.py
index 845e37cd5b43..5beef4585127 100644
--- a/homeassistant/components/venstar/entity.py
+++ b/homeassistant/components/venstar/entity.py
@@ -41,5 +41,5 @@ class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]):
name=self._client.name,
manufacturer="Venstar",
model=f"{self._client.model}-{self._client.get_type()}",
- sw_version=f"{firmware_version[0]}.{firmware_version[1]}",
+ sw_version=f"{firmware_version[0]}.{firmware_version[1]:02}",
)
diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json
index eba5c8a6cd48..ddba18fd9daa 100644
--- a/homeassistant/components/venstar/manifest.json
+++ b/homeassistant/components/venstar/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["venstarcolortouch"],
- "requirements": ["venstarcolortouch==0.21"]
+ "requirements": ["venstarcolortouch==0.22"]
}
diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py
index b8f5a1195404..681eedfef4c9 100644
--- a/homeassistant/components/version/diagnostics.py
+++ b/homeassistant/components/version/diagnostics.py
@@ -2,9 +2,10 @@
from typing import Any
-from attr import asdict
-
-from homeassistant.components.diagnostics import entity_entry_as_dict
+from homeassistant.components.diagnostics import (
+ device_entry_as_dict,
+ entity_entry_as_dict,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -45,7 +46,7 @@ async def async_get_config_entry_diagnostics(
{"entry": entity_entry_as_dict(entity), "state": state_dict}
)
- devices.append({"device": asdict(device), "entities": entities})
+ devices.append({"device": device_entry_as_dict(device), "entities": entities})
return {
"entry": config_entry.as_dict(),
diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py
index 96ea5feda36a..f803fd121c0b 100644
--- a/homeassistant/components/vesync/fan.py
+++ b/homeassistant/components/vesync/fan.py
@@ -1,7 +1,7 @@
"""Support for VeSync fans."""
import logging
-from typing import Any, override
+from typing import Any, cast, override
from pyvesync.base_devices import VeSyncFanBase, VeSyncPurifier
@@ -116,7 +116,14 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity):
) -> None:
"""Initialize the fan."""
super().__init__(device, coordinator)
- if rgetattr(device, "state.oscillation_status") is not None:
+ # Tower fans expose a single-axis ``oscillation_status`` state attribute,
+ # while pedestal fans expose ``vertical_oscillation_status`` and
+ # ``horizontal_oscillation_status`` separately. The OSCILLATE feature is
+ # advertised when either form of oscillation is available.
+ if rgetattr(device, "state.oscillation_status") is not None or (
+ rgetattr(device, "state.vertical_oscillation_status") is not None
+ or rgetattr(device, "state.horizontal_oscillation_status") is not None
+ ):
self._attr_supported_features |= FanEntityFeature.OSCILLATE
# Build maps for HA <-> VeSync preset modes
self._ha_to_vs_mode_map: dict[str, str] = {}
@@ -141,7 +148,14 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity):
@override
def oscillating(self) -> bool:
"""Return True if device is oscillating."""
- return rgetattr(self.device, "state.oscillation_status") == "on"
+ # Tower fans report a single-axis oscillation status.
+ if rgetattr(self.device, "state.oscillation_status") == "on":
+ return True
+ # Pedestal fans report vertical and horizontal oscillation separately;
+ # the fan is considered oscillating when either axis is active.
+ if rgetattr(self.device, "state.vertical_oscillation_status") == "on":
+ return True
+ return rgetattr(self.device, "state.horizontal_oscillation_status") == "on"
@property
@override
@@ -149,15 +163,15 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity):
"""Return the currently set speed."""
current_level = self.device.state.fan_level
- if (
- self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL)
- and current_level is not None
- ):
+ if self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL):
if current_level == 0:
return 0
- return ordered_list_item_to_percentage(
- self.device.fan_levels, current_level
- )
+ # The device can report an out-of-range level (e.g. -1) when the
+ # speed is not applicable; treat it as unknown instead of crashing.
+ if current_level in self.device.fan_levels:
+ return ordered_list_item_to_percentage(
+ self.device.fan_levels, current_level
+ )
return None
@property
@@ -341,14 +355,28 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity):
@override
async def async_oscillate(self, oscillating: bool) -> None:
"""Set oscillation."""
- if hasattr(self.device, "toggle_oscillation"):
- success = await self.device.toggle_oscillation(oscillating)
- if not success:
+ # Pedestal fans expose per-axis oscillation; checked first because
+ # the inherited ``toggle_oscillation`` is a no-op for them.
+ if (
+ rgetattr(self.device, "state.vertical_oscillation_status") is not None
+ or rgetattr(self.device, "state.horizontal_oscillation_status") is not None
+ ):
+ device = cast(VeSyncFanBase, self.device)
+ vertical_ok = await device.toggle_vertical_oscillation(oscillating)
+ horizontal_ok = await device.toggle_horizontal_oscillation(oscillating)
+ if not vertical_ok or not horizontal_ok:
if self.device.last_response:
raise HomeAssistantError(self.device.last_response.message)
raise HomeAssistantError(
"Failed to set oscillation, no response found."
)
self.async_write_ha_state()
- else:
+ return
+ if not hasattr(self.device, "toggle_oscillation"):
raise HomeAssistantError("Oscillation not supported by this device.")
+ success = await self.device.toggle_oscillation(oscillating)
+ if not success:
+ if self.device.last_response:
+ raise HomeAssistantError(self.device.last_response.message)
+ raise HomeAssistantError("Failed to set oscillation, no response found.")
+ self.async_write_ha_state()
diff --git a/homeassistant/components/vibration/__init__.py b/homeassistant/components/vibration/__init__.py
new file mode 100644
index 000000000000..b361746282f3
--- /dev/null
+++ b/homeassistant/components/vibration/__init__.py
@@ -0,0 +1,15 @@
+"""Integration for vibration triggers."""
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+
+DOMAIN = "vibration"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+__all__ = []
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the component."""
+ return True
diff --git a/homeassistant/components/vibration/condition.py b/homeassistant/components/vibration/condition.py
new file mode 100644
index 000000000000..ab43c4593db5
--- /dev/null
+++ b/homeassistant/components/vibration/condition.py
@@ -0,0 +1,25 @@
+"""Provides conditions for vibration."""
+
+from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
+ BinarySensorDeviceClass,
+)
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.automation import DomainSpec
+from homeassistant.helpers.condition import Condition, make_entity_state_condition
+
+VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = {
+ BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION),
+}
+
+
+CONDITIONS: dict[str, type[Condition]] = {
+ "is_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_ON),
+ "is_not_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_OFF),
+}
+
+
+async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
+ """Return the conditions for vibration."""
+ return CONDITIONS
diff --git a/homeassistant/components/vibration/conditions.yaml b/homeassistant/components/vibration/conditions.yaml
new file mode 100644
index 000000000000..5f5bb66d8aaa
--- /dev/null
+++ b/homeassistant/components/vibration/conditions.yaml
@@ -0,0 +1,26 @@
+.condition_common_fields: &condition_common_fields
+ behavior:
+ required: true
+ default: any
+ selector:
+ automation_behavior:
+ mode: condition
+ for:
+ required: true
+ default: 00:00:00
+ selector:
+ duration:
+
+is_detected:
+ fields: *condition_common_fields
+ target:
+ entity:
+ - domain: binary_sensor
+ device_class: vibration
+
+is_not_detected:
+ fields: *condition_common_fields
+ target:
+ entity:
+ - domain: binary_sensor
+ device_class: vibration
diff --git a/homeassistant/components/vibration/icons.json b/homeassistant/components/vibration/icons.json
new file mode 100644
index 000000000000..d51de741bf87
--- /dev/null
+++ b/homeassistant/components/vibration/icons.json
@@ -0,0 +1,18 @@
+{
+ "conditions": {
+ "is_detected": {
+ "condition": "mdi:vibrate"
+ },
+ "is_not_detected": {
+ "condition": "mdi:vibrate-off"
+ }
+ },
+ "triggers": {
+ "cleared": {
+ "trigger": "mdi:vibrate-off"
+ },
+ "detected": {
+ "trigger": "mdi:vibrate"
+ }
+ }
+}
diff --git a/homeassistant/components/vibration/manifest.json b/homeassistant/components/vibration/manifest.json
new file mode 100644
index 000000000000..e875b7c6c583
--- /dev/null
+++ b/homeassistant/components/vibration/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "vibration",
+ "name": "Vibration",
+ "codeowners": ["@home-assistant/core"],
+ "documentation": "https://www.home-assistant.io/integrations/vibration",
+ "integration_type": "system",
+ "quality_scale": "internal"
+}
diff --git a/homeassistant/components/vibration/strings.json b/homeassistant/components/vibration/strings.json
new file mode 100644
index 000000000000..b1b3898251cf
--- /dev/null
+++ b/homeassistant/components/vibration/strings.json
@@ -0,0 +1,61 @@
+{
+ "common": {
+ "condition_behavior_name": "Condition passes if",
+ "condition_for_name": "For at least",
+ "trigger_behavior_name": "Trigger when",
+ "trigger_for_name": "For at least"
+ },
+ "conditions": {
+ "is_detected": {
+ "description": "Tests if one or more vibration sensors are detecting vibration.",
+ "fields": {
+ "behavior": {
+ "name": "[%key:component::vibration::common::condition_behavior_name%]"
+ },
+ "for": {
+ "name": "[%key:component::vibration::common::condition_for_name%]"
+ }
+ },
+ "name": "Vibration is detected"
+ },
+ "is_not_detected": {
+ "description": "Tests if one or more vibration sensors are not detecting vibration.",
+ "fields": {
+ "behavior": {
+ "name": "[%key:component::vibration::common::condition_behavior_name%]"
+ },
+ "for": {
+ "name": "[%key:component::vibration::common::condition_for_name%]"
+ }
+ },
+ "name": "Vibration is not detected"
+ }
+ },
+ "title": "Vibration",
+ "triggers": {
+ "cleared": {
+ "description": "Triggers when one or more vibration sensors stop detecting vibration.",
+ "fields": {
+ "behavior": {
+ "name": "[%key:component::vibration::common::trigger_behavior_name%]"
+ },
+ "for": {
+ "name": "[%key:component::vibration::common::trigger_for_name%]"
+ }
+ },
+ "name": "Vibration cleared"
+ },
+ "detected": {
+ "description": "Triggers when one or more vibration sensors start detecting vibration.",
+ "fields": {
+ "behavior": {
+ "name": "[%key:component::vibration::common::trigger_behavior_name%]"
+ },
+ "for": {
+ "name": "[%key:component::vibration::common::trigger_for_name%]"
+ }
+ },
+ "name": "Vibration detected"
+ }
+ }
+}
diff --git a/homeassistant/components/vibration/trigger.py b/homeassistant/components/vibration/trigger.py
new file mode 100644
index 000000000000..a23a62401660
--- /dev/null
+++ b/homeassistant/components/vibration/trigger.py
@@ -0,0 +1,24 @@
+"""Provides triggers for vibration."""
+
+from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
+ BinarySensorDeviceClass,
+)
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.automation import DomainSpec
+from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
+
+VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = {
+ BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION),
+}
+
+TRIGGERS: dict[str, type[Trigger]] = {
+ "detected": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_ON),
+ "cleared": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_OFF),
+}
+
+
+async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
+ """Return the triggers for vibration."""
+ return TRIGGERS
diff --git a/homeassistant/components/vibration/triggers.yaml b/homeassistant/components/vibration/triggers.yaml
new file mode 100644
index 000000000000..0957393172c3
--- /dev/null
+++ b/homeassistant/components/vibration/triggers.yaml
@@ -0,0 +1,26 @@
+.trigger_common_fields: &trigger_common_fields
+ behavior:
+ required: true
+ default: each
+ selector:
+ automation_behavior:
+ mode: trigger
+ for:
+ required: true
+ default: 00:00:00
+ selector:
+ duration:
+
+detected:
+ fields: *trigger_common_fields
+ target:
+ entity:
+ - domain: binary_sensor
+ device_class: vibration
+
+cleared:
+ fields: *trigger_common_fields
+ target:
+ entity:
+ - domain: binary_sensor
+ device_class: vibration
diff --git a/homeassistant/components/vicare/application_credentials.py b/homeassistant/components/vicare/application_credentials.py
index 6d2773d504c6..4e794091ae0b 100644
--- a/homeassistant/components/vicare/application_credentials.py
+++ b/homeassistant/components/vicare/application_credentials.py
@@ -18,6 +18,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
VICARE_SCOPES = [SCOPE_IOT, SCOPE_OFFLINE_ACCESS]
+async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
+ """Return description placeholders for the credentials dialog."""
+ return {
+ "more_info_url": "https://www.home-assistant.io/integrations/vicare/#prerequisites"
+ }
+
+
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> ViCareOAuth2Implementation:
diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py
index 5ae572c9fffb..99ab2235bf96 100644
--- a/homeassistant/components/vicare/climate.py
+++ b/homeassistant/components/vicare/climate.py
@@ -41,6 +41,7 @@ SERVICE_SET_VICARE_MODE = "set_vicare_mode"
SERVICE_SET_VICARE_MODE_ATTR_MODE = "vicare_mode"
VICARE_MODE_DHW = "dhw"
+VICARE_MODE_COOLING = "cooling"
VICARE_MODE_HEATING = "heating"
VICARE_MODE_HEATINGCOOLING = "heatingCooling"
VICARE_MODE_DHWANDHEATING = "dhwAndHeating"
@@ -64,6 +65,7 @@ VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = {
VICARE_MODE_DHWANDHEATING: HVACMode.AUTO,
VICARE_MODE_HEATINGCOOLING: HVACMode.AUTO,
VICARE_MODE_HEATING: HVACMode.AUTO,
+ VICARE_MODE_COOLING: HVACMode.COOL,
VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT,
}
diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json
index 55ba55642566..78e66edf31af 100644
--- a/homeassistant/components/vicare/manifest.json
+++ b/homeassistant/components/vicare/manifest.json
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
- "requirements": ["PyViCare==2.60.2"]
+ "requirements": ["PyViCare==2.61.0"]
}
diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py
index 41fb2cc34ed3..dd52d40aae22 100644
--- a/homeassistant/components/vicare/sensor.py
+++ b/homeassistant/components/vicare/sensor.py
@@ -8,6 +8,7 @@ from typing import override
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
+from PyViCare.PyViCareFloorHeating import FloorHeating
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
@@ -1274,6 +1275,16 @@ CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
SUPPLY_TEMPERATURE_SENSOR,
)
+FLOOR_HEATING_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
+ ViCareSensorEntityDescription(
+ key="active_mode",
+ translation_key="active_mode",
+ device_class=SensorDeviceClass.ENUM,
+ options=["cooling", "heating", "standby"],
+ value_getter=lambda api: api.getActiveMode(),
+ ),
+)
+
BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription(
key="burner_starts",
@@ -1509,6 +1520,18 @@ def _build_entities(
for description in GLOBAL_SENSORS
if is_supported(description.key, description.value_getter, device.api)
)
+ # add device-class-specific entities
+ if isinstance(device.api, FloorHeating):
+ entities.extend(
+ ViCareSensor(
+ description,
+ get_device_serial(device.api),
+ device.config,
+ device.api,
+ )
+ for description in FLOOR_HEATING_SENSORS
+ if is_supported(description.key, description.value_getter, device.api)
+ )
# add component entities
for component_list, entity_description_list in (
(get_circuits(device.api), CIRCUIT_SENSORS),
diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json
index 314dfd44e23f..a971d8d5d6a7 100644
--- a/homeassistant/components/vicare/strings.json
+++ b/homeassistant/components/vicare/strings.json
@@ -1,4 +1,7 @@
{
+ "application_credentials": {
+ "description": "The **client secret** is ignored, enter any value. Follow the [setup instructions]({more_info_url}) to obtain the Client ID."
+ },
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@@ -161,6 +164,14 @@
}
},
"sensor": {
+ "active_mode": {
+ "name": "Mode",
+ "state": {
+ "cooling": "Cooling",
+ "heating": "Heating",
+ "standby": "[%key:common::state::standby%]"
+ }
+ },
"boiler_supply_temperature": {
"name": "Boiler supply temperature"
},
diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json
index dc81bb9aeb9b..a14b7b43f7ff 100644
--- a/homeassistant/components/victron_gx/manifest.json
+++ b/homeassistant/components/victron_gx/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "platinum",
- "requirements": ["victron-mqtt==2026.6.6"],
+ "requirements": ["victron-mqtt==2026.7.4"],
"ssdp": [
{
"X_MqttOnLan": "1",
diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json
index 084bbc8bdc60..dc4cd29e0326 100644
--- a/homeassistant/components/victron_gx/strings.json
+++ b/homeassistant/components/victron_gx/strings.json
@@ -94,6 +94,7 @@
"temperature": "Temperature",
"terminals_overheated": "Terminals overheated",
"total_energy": "Total energy",
+ "total_pv_yield_system": "Total PV yield system",
"total_pv_yield_user": "Total PV yield user",
"total_yield": "Total yield",
"unknown": "Unknown",
@@ -325,9 +326,6 @@
"hub4_ac_grid_setpoint": {
"name": "AC grid setpoint"
},
- "multi_ess_ac_power_setpoint": {
- "name": "ESS AC power setpoint"
- },
"multi_ess_min_soc_limit": {
"name": "ESS minimum SoC limit"
},
@@ -405,6 +403,13 @@
"passthrough": "[%key:component::victron_gx::common::passthrough%]"
}
},
+ "battery_bms_mode": {
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]",
+ "standby": "[%key:common::state::standby%]"
+ }
+ },
"evcharger_mode": {
"name": "[%key:common::config_flow::data::mode%]",
"state": {
@@ -493,6 +498,14 @@
"sell": "Sell"
}
},
+ "system_vrm_portal_mode": {
+ "name": "VRM portal access level",
+ "state": {
+ "full": "[%key:common::state::full%]",
+ "off": "[%key:common::state::off%]",
+ "read_only": "Read-only"
+ }
+ },
"vebus_inverter_mode": {
"state": {
"charger_only": "[%key:component::victron_gx::common::charger_only%]",
@@ -1317,7 +1330,7 @@
}
},
"inverter_total_pv_yield_system": {
- "name": "Total PV yield system"
+ "name": "[%key:component::victron_gx::common::total_pv_yield_system%]"
},
"inverter_total_pv_yield_user": {
"name": "[%key:component::victron_gx::common::total_pv_yield_user%]"
@@ -1398,6 +1411,9 @@
"multi_dc_temperature": {
"name": "[%key:component::victron_gx::common::dc_temperature%]"
},
+ "multi_ess_ac_power_setpoint": {
+ "name": "ESS AC power setpoint"
+ },
"multi_ess_mode": {
"name": "[%key:component::victron_gx::common::ess_mode%]",
"state": {
@@ -1628,6 +1644,9 @@
"sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]"
}
},
+ "solarcharger_temperature": {
+ "name": "[%key:component::victron_gx::common::temperature%]"
+ },
"solarcharger_time_in_absorption_today": {
"name": "Time in absorption today"
},
@@ -1637,6 +1656,9 @@
"solarcharger_time_in_float_today": {
"name": "Time in float today"
},
+ "solarcharger_total_pv_yield_system": {
+ "name": "[%key:component::victron_gx::common::total_pv_yield_system%]"
+ },
"solarcharger_tracker_tracker_current": {
"name": "PV tracker {tracker} current"
},
@@ -1884,6 +1906,16 @@
"system_heartbeat": {
"name": "GX system heartbeat"
},
+ "system_pv_on_grid_current_phase": {
+ "name": "PV on grid current {phase}"
+ },
+ "system_pv_on_grid_phases": {
+ "name": "PV on grid phases",
+ "unit_of_measurement": "phases"
+ },
+ "system_pv_on_grid_power_phase": {
+ "name": "PV on grid power {phase}"
+ },
"system_pv_on_output_current_phase": {
"name": "PV on output current {phase}"
},
@@ -2254,6 +2286,9 @@
"system_dvcc": {
"name": "DVCC"
},
+ "system_ess_always_peak_shave": {
+ "name": "ESS always peak shave"
+ },
"system_ess_battery_use": {
"name": "ESS only critical loads from battery"
},
@@ -2272,6 +2307,18 @@
"vebus_device_device_number_power_assist_enabled": {
"name": "{device_number} PowerAssist enabled"
},
+ "vebus_hub4_disable_charge": {
+ "name": "Hub4 disable charge"
+ },
+ "vebus_hub4_do_not_feed_in_overvoltage": {
+ "name": "Hub4 do not feed in on overvoltage"
+ },
+ "vebus_hub4_fix_solar_offset_100mv": {
+ "name": "Hub4 fix solar offset to 100mV"
+ },
+ "vebus_hub4_target_power_is_max_feed_in": {
+ "name": "Hub4 target power is max feed-in"
+ },
"vebus_inverter_ignoreacin1_onoff_control": {
"name": "Control ignore AC-in-1"
},
@@ -2280,6 +2327,9 @@
},
"vebus_inverter_setting_alarm_grid_lost": {
"name": "Grid lost alarm setting"
+ },
+ "vebus_pvinverter_disable": {
+ "name": "Vebus PV inverter disable"
}
},
"time": {
diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py
index 42dc69b4361d..d7a01cbd6578 100644
--- a/homeassistant/components/vistapool/__init__.py
+++ b/homeassistant/components/vistapool/__init__.py
@@ -1,21 +1,30 @@
"""The Vistapool integration."""
+import asyncio
from dataclasses import dataclass, field
import logging
-from aioaquarite import AquariteAuth, AquariteClient, AquariteError, AuthenticationError
+from aioaquarite import (
+ AquariteAuth,
+ AquariteClient,
+ AquariteError,
+ AuthenticationError,
+ ResilientUserPoolsSubscription,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .const import DOMAIN
+from .const import DOMAIN, SIGNAL_NEW_POOL
from .coordinator import VistapoolDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -39,6 +48,7 @@ class VistapoolData:
coordinators: dict[str, VistapoolDataUpdateCoordinator] = field(
default_factory=dict
)
+ sync_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
type VistapoolConfigEntry = ConfigEntry[VistapoolData]
@@ -77,29 +87,148 @@ async def async_setup_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) ->
)
data = VistapoolData(auth=auth, api=api)
+ entry.runtime_data = data
try:
for pool_id, pool_name in pools.items():
- coordinator = VistapoolDataUpdateCoordinator(
- hass, entry, auth, api, pool_id, pool_name
- )
- data.coordinators[pool_id] = coordinator
- await coordinator.async_config_entry_first_refresh()
- try:
- await coordinator.subscribe()
- except AquariteError as exc:
- raise ConfigEntryNotReady from exc
- entry.async_on_unload(coordinator.async_shutdown)
+ await _async_add_coordinator(hass, entry, pool_id, pool_name, first=True)
except Exception:
for coordinator in data.coordinators.values():
await coordinator.async_shutdown()
raise
- entry.runtime_data = data
+ # Catch pools removed from the account while Home Assistant was offline; the
+ # first live snapshot is a no-op so it wouldn't clean these up.
+ _async_remove_stale_devices(hass, entry, set(pools))
+
+ def _on_user_pools_snapshot(pool_ids: list[str]) -> None:
+ """Bridge the Firestore snapshot from the watch thread to the HA loop."""
+ hass.loop.call_soon_threadsafe(_schedule_reconcile, pool_ids)
+
+ @callback
+ def _schedule_reconcile(pool_ids: list[str]) -> None:
+ entry.async_create_background_task(
+ hass,
+ _async_reconcile_pools(hass, entry, pool_ids),
+ name=f"vistapool_reconcile_{entry.entry_id}",
+ )
+
+ # Subscribe before forwarding platforms so a failed subscribe doesn't leave
+ # platforms set up; on retry they would re-forward and raise "already setup".
+ try:
+ subscription: ResilientUserPoolsSubscription = (
+ await api.subscribe_user_pools_resilient(_on_user_pools_snapshot)
+ )
+ except AquariteError as exc:
+ for coordinator in data.coordinators.values():
+ await coordinator.async_shutdown()
+ raise ConfigEntryNotReady from exc
+ entry.async_on_unload(subscription.aclose)
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> bool:
"""Unload Vistapool config entry."""
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ if unload_ok:
+ # Hold sync_lock so a reconcile background task can't mutate the
+ # coordinators dict while we shut it down.
+ async with entry.runtime_data.sync_lock:
+ for coordinator in entry.runtime_data.coordinators.values():
+ await coordinator.async_shutdown()
+ return unload_ok
+
+
+@callback
+def _async_remove_stale_devices(
+ hass: HomeAssistant, entry: VistapoolConfigEntry, valid_pool_ids: set[str]
+) -> None:
+ """Remove registry devices for pools no longer present on the account."""
+ device_registry = dr.async_get(hass)
+ for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
+ pool_id = next((i[1] for i in device.identifiers if i[0] == DOMAIN), None)
+ if pool_id is not None and pool_id not in valid_pool_ids:
+ device_registry.async_remove_device(device.id)
+
+
+async def _async_initial_refresh(
+ coordinator: VistapoolDataUpdateCoordinator, *, first: bool
+) -> None:
+ """Populate coordinator data for a pool; raise if it would stay empty."""
+ if first:
+ await coordinator.async_config_entry_first_refresh()
+ return
+ await coordinator.async_refresh()
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ )
+
+
+async def _async_add_coordinator(
+ hass: HomeAssistant,
+ entry: VistapoolConfigEntry,
+ pool_id: str,
+ pool_name: str,
+ *,
+ first: bool,
+) -> VistapoolDataUpdateCoordinator:
+ """Create, refresh and subscribe a coordinator for a single pool."""
+ coordinator = VistapoolDataUpdateCoordinator(
+ hass, entry, entry.runtime_data.auth, entry.runtime_data.api, pool_id, pool_name
+ )
+ try:
+ await _async_initial_refresh(coordinator, first=first)
+ try:
+ await coordinator.subscribe()
+ except AquariteError as exc:
+ raise ConfigEntryNotReady from exc
+ except ConfigEntryNotReady:
+ await coordinator.async_shutdown()
+ raise
+ entry.runtime_data.coordinators[pool_id] = coordinator
+ return coordinator
+
+
+async def _async_reconcile_pools(
+ hass: HomeAssistant,
+ entry: VistapoolConfigEntry,
+ pool_ids: list[str],
+) -> None:
+ """Reconcile the runtime coordinator set against a fresh pool ID list."""
+ async with entry.runtime_data.sync_lock:
+ current = set(entry.runtime_data.coordinators)
+ fetched = set(pool_ids)
+ if current == fetched:
+ return
+
+ new_ids = fetched - current
+ names: dict[str, str] = {}
+ if new_ids:
+ try:
+ names = await entry.runtime_data.api.get_pools()
+ except AquariteError as err:
+ _LOGGER.debug("Pool name lookup failed during reconcile: %s", err)
+ new_ids = set()
+
+ for pool_id in new_ids:
+ if pool_id not in names:
+ continue
+ try:
+ coordinator = await _async_add_coordinator(
+ hass, entry, pool_id, names[pool_id], first=False
+ )
+ except ConfigEntryNotReady as err:
+ _LOGGER.warning("Failed to add new pool %s: %s", pool_id, err)
+ continue
+ async_dispatcher_send(
+ hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", coordinator
+ )
+
+ if stale := current - fetched:
+ for pool_id in stale:
+ await entry.runtime_data.coordinators.pop(pool_id).async_shutdown()
+ _async_remove_stale_devices(hass, entry, fetched)
diff --git a/homeassistant/components/vistapool/binary_sensor.py b/homeassistant/components/vistapool/binary_sensor.py
index 864c6f89d4f1..dc72edd624e3 100644
--- a/homeassistant/components/vistapool/binary_sensor.py
+++ b/homeassistant/components/vistapool/binary_sensor.py
@@ -9,7 +9,8 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VistapoolConfigEntry
@@ -20,6 +21,7 @@ from .const import (
PATH_HASIO,
PATH_HASPH,
PATH_HASRX,
+ SIGNAL_NEW_POOL,
)
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity
@@ -162,6 +164,46 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[VistapoolBinarySensorEntityDescription, ...] =
)
+def _build_binary_sensor_entities(
+ coordinator: VistapoolDataUpdateCoordinator,
+) -> list[BinarySensorEntity]:
+ """Build the binary sensor entities for a single pool."""
+ entities: list[BinarySensorEntity] = []
+ for description in BINARY_SENSOR_DESCRIPTIONS:
+ if description.exists_path is not None:
+ required = (
+ (description.exists_path,)
+ if isinstance(description.exists_path, str)
+ else description.exists_path
+ )
+ if not all(coordinator.get_value(path) for path in required):
+ continue
+ entities.append(VistapoolBinarySensor(coordinator, description))
+
+ if coordinator.get_value(PATH_HASHIDRO):
+ is_electrolysis = coordinator.get_value("hidro.is_electrolysis")
+ entities.append(
+ VistapoolBinarySensor(
+ coordinator,
+ VistapoolBinarySensorEntityDescription(
+ key="electrolysis_low" if is_electrolysis else "hydrolysis_low",
+ translation_key=(
+ "electrolysis_low" if is_electrolysis else "hydrolysis_low"
+ ),
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ value_path="hidro.low",
+ ),
+ )
+ )
+
+ if any(
+ coordinator.get_value(path)
+ for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX)
+ ):
+ entities.append(VistapoolDosingTankBinarySensor(coordinator))
+ return entities
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: VistapoolConfigEntry,
@@ -169,43 +211,20 @@ async def async_setup_entry(
) -> None:
"""Set up Vistapool binary sensors for every pool on the account."""
entities: list[BinarySensorEntity] = []
-
for coordinator in entry.runtime_data.coordinators.values():
- for description in BINARY_SENSOR_DESCRIPTIONS:
- if description.exists_path is not None:
- required = (
- (description.exists_path,)
- if isinstance(description.exists_path, str)
- else description.exists_path
- )
- if not all(coordinator.get_value(path) for path in required):
- continue
- entities.append(VistapoolBinarySensor(coordinator, description))
-
- if coordinator.get_value(PATH_HASHIDRO):
- is_electrolysis = coordinator.get_value("hidro.is_electrolysis")
- entities.append(
- VistapoolBinarySensor(
- coordinator,
- VistapoolBinarySensorEntityDescription(
- key="electrolysis_low" if is_electrolysis else "hydrolysis_low",
- translation_key=(
- "electrolysis_low" if is_electrolysis else "hydrolysis_low"
- ),
- device_class=BinarySensorDeviceClass.PROBLEM,
- value_path="hidro.low",
- ),
- )
- )
-
- if any(
- coordinator.get_value(path)
- for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX)
- ):
- entities.append(VistapoolDosingTankBinarySensor(coordinator))
-
+ entities.extend(_build_binary_sensor_entities(coordinator))
async_add_entities(entities)
+ @callback
+ def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None:
+ async_add_entities(_build_binary_sensor_entities(coordinator))
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool
+ )
+ )
+
class VistapoolBinarySensor(VistapoolEntity, BinarySensorEntity):
"""Generic Vistapool binary sensor driven by an entity description."""
diff --git a/homeassistant/components/vistapool/button.py b/homeassistant/components/vistapool/button.py
index bfc825807b97..0524f84226d1 100644
--- a/homeassistant/components/vistapool/button.py
+++ b/homeassistant/components/vistapool/button.py
@@ -6,12 +6,13 @@ from typing import override
from aioaquarite import AquariteError
from homeassistant.components.button import ButtonEntity
-from homeassistant.core import HomeAssistant
+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 AddConfigEntryEntitiesCallback
from . import VistapoolConfigEntry
-from .const import DOMAIN
+from .const import DOMAIN, SIGNAL_NEW_POOL
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity
@@ -22,16 +23,34 @@ _LIGHT_STATUS_PATH = "light.status"
_LED_PULSE_DELAY_SECONDS = 1.0
+def _build_button_entities(
+ coordinator: VistapoolDataUpdateCoordinator,
+) -> list[VistapoolLEDPulseButton]:
+ """Build the button entities for a single pool."""
+ if not coordinator.get_value(_HASLED_PATH):
+ return []
+ return [VistapoolLEDPulseButton(coordinator)]
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: VistapoolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vistapool buttons for every pool that has an LED fixture."""
- async_add_entities(
- VistapoolLEDPulseButton(coordinator)
- for coordinator in entry.runtime_data.coordinators.values()
- if coordinator.get_value(_HASLED_PATH)
+ entities: list[VistapoolLEDPulseButton] = []
+ for coordinator in entry.runtime_data.coordinators.values():
+ entities.extend(_build_button_entities(coordinator))
+ async_add_entities(entities)
+
+ @callback
+ def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None:
+ async_add_entities(_build_button_entities(coordinator))
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool
+ )
)
diff --git a/homeassistant/components/vistapool/const.py b/homeassistant/components/vistapool/const.py
index f7e30b95aaa9..f2897f2f86d4 100644
--- a/homeassistant/components/vistapool/const.py
+++ b/homeassistant/components/vistapool/const.py
@@ -12,3 +12,5 @@ PATH_HASPH = f"{PATH_PREFIX}hasPH"
PATH_HASRX = f"{PATH_PREFIX}hasRX"
PATH_HASUV = f"{PATH_PREFIX}hasUV"
PATH_HASHIDRO = f"{PATH_PREFIX}hasHidro"
+
+SIGNAL_NEW_POOL = f"{DOMAIN}_new_pool"
diff --git a/homeassistant/components/vistapool/light.py b/homeassistant/components/vistapool/light.py
index f4d05a23db19..6967a233d5dd 100644
--- a/homeassistant/components/vistapool/light.py
+++ b/homeassistant/components/vistapool/light.py
@@ -5,12 +5,13 @@ from typing import Any, override
from aioaquarite import AquariteError
from homeassistant.components.light import ColorMode, LightEntity
-from homeassistant.core import HomeAssistant
+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 AddConfigEntryEntitiesCallback
from . import VistapoolConfigEntry
-from .const import DOMAIN
+from .const import DOMAIN, SIGNAL_NEW_POOL
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity
@@ -30,6 +31,16 @@ async def async_setup_entry(
for coordinator in entry.runtime_data.coordinators.values()
)
+ @callback
+ def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None:
+ async_add_entities([VistapoolLight(coordinator)])
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool
+ )
+ )
+
class VistapoolLight(VistapoolEntity, LightEntity):
"""Representation of a Vistapool pool light."""
diff --git a/homeassistant/components/vistapool/manifest.json b/homeassistant/components/vistapool/manifest.json
index bc07c75d6683..6e8106720570 100644
--- a/homeassistant/components/vistapool/manifest.json
+++ b/homeassistant/components/vistapool/manifest.json
@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["aioaquarite"],
"quality_scale": "bronze",
- "requirements": ["aioaquarite==0.5.1"]
+ "requirements": ["aioaquarite==0.6.1"]
}
diff --git a/homeassistant/components/vistapool/number.py b/homeassistant/components/vistapool/number.py
index 2c8572ccec41..0e4fa5787625 100644
--- a/homeassistant/components/vistapool/number.py
+++ b/homeassistant/components/vistapool/number.py
@@ -16,12 +16,13 @@ from homeassistant.const import (
UnitOfElectricPotential,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant
+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 AddConfigEntryEntitiesCallback
from . import VistapoolConfigEntry
-from .const import DOMAIN, PATH_HASHIDRO, PATH_HASPH, PATH_HASRX
+from .const import DOMAIN, PATH_HASHIDRO, PATH_HASPH, PATH_HASRX, SIGNAL_NEW_POOL
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity
@@ -138,6 +139,48 @@ NUMBER_DESCRIPTIONS: tuple[VistapoolNumberEntityDescription, ...] = (
)
+def _build_number_entities(
+ coordinator: VistapoolDataUpdateCoordinator,
+) -> list[NumberEntity]:
+ """Build the number entities for a single pool."""
+ entities: list[NumberEntity] = []
+ for description in NUMBER_DESCRIPTIONS:
+ if description.exists_path is not None:
+ required = (
+ (description.exists_path,)
+ if isinstance(description.exists_path, str)
+ else description.exists_path
+ )
+ if not all(coordinator.get_value(path) for path in required):
+ continue
+ entities.append(VistapoolNumber(coordinator, description))
+
+ if coordinator.get_value(PATH_HASHIDRO):
+ key = (
+ "hydrolysis_setpoint"
+ if coordinator.get_value("hidro.is_electrolysis") is False
+ else "electrolysis_setpoint"
+ )
+ entities.append(
+ VistapoolNumber(
+ coordinator,
+ VistapoolNumberEntityDescription(
+ key=key,
+ translation_key=key,
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=0,
+ native_max_value=50.0,
+ native_step=0.1,
+ native_unit_of_measurement="g/h",
+ value_path="hidro.level",
+ scale=10,
+ max_value_fn=_max_electrolysis,
+ ),
+ )
+ )
+ return entities
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: VistapoolConfigEntry,
@@ -145,45 +188,20 @@ async def async_setup_entry(
) -> None:
"""Set up Vistapool number entities for every pool on the account."""
entities: list[NumberEntity] = []
-
for coordinator in entry.runtime_data.coordinators.values():
- for description in NUMBER_DESCRIPTIONS:
- if description.exists_path is not None:
- required = (
- (description.exists_path,)
- if isinstance(description.exists_path, str)
- else description.exists_path
- )
- if not all(coordinator.get_value(path) for path in required):
- continue
- entities.append(VistapoolNumber(coordinator, description))
-
- if coordinator.get_value(PATH_HASHIDRO):
- key = (
- "hydrolysis_setpoint"
- if coordinator.get_value("hidro.is_electrolysis") is False
- else "electrolysis_setpoint"
- )
- entities.append(
- VistapoolNumber(
- coordinator,
- VistapoolNumberEntityDescription(
- key=key,
- translation_key=key,
- entity_category=EntityCategory.CONFIG,
- native_min_value=0,
- native_max_value=50.0,
- native_step=0.1,
- native_unit_of_measurement="g/h",
- value_path="hidro.level",
- scale=10,
- max_value_fn=_max_electrolysis,
- ),
- )
- )
-
+ entities.extend(_build_number_entities(coordinator))
async_add_entities(entities)
+ @callback
+ def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None:
+ async_add_entities(_build_number_entities(coordinator))
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool
+ )
+ )
+
class VistapoolNumber(VistapoolEntity, NumberEntity):
"""Generic Vistapool number driven by an entity description."""
diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml
index 4452457150fd..a39a93b7b22b 100644
--- a/homeassistant/components/vistapool/quality_scale.yaml
+++ b/homeassistant/components/vistapool/quality_scale.yaml
@@ -35,9 +35,11 @@ rules:
docs-configuration-parameters:
status: exempt
comment: No options flow
+ docs-installation-parameters: todo
docs-troubleshooting: done
entity-category: done
entity-disabled-by-default: done
+ entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: done
@@ -57,7 +59,8 @@ rules:
docs-supported-devices: done
docs-supported-functions: done
docs-use-cases: done
- dynamic-devices: todo
+ dynamic-devices: done
+ entity-device-class: todo
entity-translations: done
exception-translations: done
icon-translations: done
diff --git a/homeassistant/components/vistapool/select.py b/homeassistant/components/vistapool/select.py
index 97e98b8429a7..8db7d4ed06ca 100644
--- a/homeassistant/components/vistapool/select.py
+++ b/homeassistant/components/vistapool/select.py
@@ -7,12 +7,13 @@ from aioaquarite import AquariteError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
-from homeassistant.core import HomeAssistant
+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 AddConfigEntryEntitiesCallback
from . import VistapoolConfigEntry
-from .const import DOMAIN
+from .const import DOMAIN, SIGNAL_NEW_POOL
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity
@@ -60,6 +61,24 @@ SELECT_DESCRIPTIONS: tuple[VistapoolSelectEntityDescription, ...] = (
)
+def _build_select_entities(
+ coordinator: VistapoolDataUpdateCoordinator,
+) -> list[SelectEntity]:
+ """Build the select entities for a single pool."""
+ entities: list[SelectEntity] = []
+ for description in SELECT_DESCRIPTIONS:
+ if description.exists_path is not None:
+ required = (
+ (description.exists_path,)
+ if isinstance(description.exists_path, str)
+ else description.exists_path
+ )
+ if not all(coordinator.get_value(path) for path in required):
+ continue
+ entities.append(VistapoolSelect(coordinator, description))
+ return entities
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: VistapoolConfigEntry,
@@ -67,21 +86,20 @@ async def async_setup_entry(
) -> None:
"""Set up Vistapool select entities for every pool on the account."""
entities: list[SelectEntity] = []
-
for coordinator in entry.runtime_data.coordinators.values():
- for description in SELECT_DESCRIPTIONS:
- if description.exists_path is not None:
- required = (
- (description.exists_path,)
- if isinstance(description.exists_path, str)
- else description.exists_path
- )
- if not all(coordinator.get_value(path) for path in required):
- continue
- entities.append(VistapoolSelect(coordinator, description))
-
+ entities.extend(_build_select_entities(coordinator))
async_add_entities(entities)
+ @callback
+ def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None:
+ async_add_entities(_build_select_entities(coordinator))
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool
+ )
+ )
+
def _to_index(raw: Any) -> int | None:
"""Convert a coordinator value into an options-list index, or None if not possible."""
diff --git a/homeassistant/components/vistapool/sensor.py b/homeassistant/components/vistapool/sensor.py
index 21bde1bdf4ce..5ecb8a37622f 100644
--- a/homeassistant/components/vistapool/sensor.py
+++ b/homeassistant/components/vistapool/sensor.py
@@ -17,7 +17,8 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VistapoolConfigEntry
@@ -28,6 +29,7 @@ from .const import (
PATH_HASPH,
PATH_HASRX,
PATH_HASUV,
+ SIGNAL_NEW_POOL,
)
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity
@@ -125,6 +127,39 @@ SENSOR_DESCRIPTIONS: tuple[VistapoolSensorEntityDescription, ...] = (
)
+def _build_sensor_entities(
+ coordinator: VistapoolDataUpdateCoordinator,
+) -> list[VistapoolSensorEntity]:
+ """Build the sensor entities for a single pool."""
+ entities: list[VistapoolSensorEntity] = []
+ for description in SENSOR_DESCRIPTIONS:
+ if description.exists_path is not None and not coordinator.get_value(
+ description.exists_path
+ ):
+ continue
+ entities.append(VistapoolSensorEntity(coordinator, description))
+
+ # Electrolysis/hydrolysis: dynamic key based on hardware type
+ if coordinator.get_value(PATH_HASHIDRO):
+ is_electrolysis = coordinator.get_value("hidro.is_electrolysis")
+ entities.append(
+ VistapoolSensorEntity(
+ coordinator,
+ VistapoolSensorEntityDescription(
+ key="electrolysis" if is_electrolysis else "hydrolysis",
+ translation_key=(
+ "electrolysis" if is_electrolysis else "hydrolysis"
+ ),
+ native_unit_of_measurement="g/h",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_path="hidro.current",
+ value_fn=_convert_tenths,
+ ),
+ )
+ )
+ return entities
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: VistapoolConfigEntry,
@@ -132,36 +167,20 @@ async def async_setup_entry(
) -> None:
"""Set up Vistapool sensors for every pool on the account."""
entities: list[VistapoolSensorEntity] = []
-
for coordinator in entry.runtime_data.coordinators.values():
- for description in SENSOR_DESCRIPTIONS:
- if description.exists_path is not None and not coordinator.get_value(
- description.exists_path
- ):
- continue
- entities.append(VistapoolSensorEntity(coordinator, description))
-
- # Electrolysis/hydrolysis: dynamic key based on hardware type
- if coordinator.get_value(PATH_HASHIDRO):
- is_electrolysis = coordinator.get_value("hidro.is_electrolysis")
- entities.append(
- VistapoolSensorEntity(
- coordinator,
- VistapoolSensorEntityDescription(
- key="electrolysis" if is_electrolysis else "hydrolysis",
- translation_key=(
- "electrolysis" if is_electrolysis else "hydrolysis"
- ),
- native_unit_of_measurement="g/h",
- state_class=SensorStateClass.MEASUREMENT,
- value_path="hidro.current",
- value_fn=_convert_tenths,
- ),
- )
- )
-
+ entities.extend(_build_sensor_entities(coordinator))
async_add_entities(entities)
+ @callback
+ def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None:
+ async_add_entities(_build_sensor_entities(coordinator))
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool
+ )
+ )
+
class VistapoolSensorEntity(VistapoolEntity, SensorEntity):
"""Generic Vistapool sensor driven by an entity description."""
diff --git a/homeassistant/components/vizio/remote.py b/homeassistant/components/vizio/remote.py
index 3b44dffbd3a4..d5b86fc2c882 100644
--- a/homeassistant/components/vizio/remote.py
+++ b/homeassistant/components/vizio/remote.py
@@ -23,6 +23,32 @@ from .coordinator import VizioConfigEntry, VizioDeviceCoordinator
PARALLEL_UPDATES = 0
+# Maps native pyvizio key names to human-friendly aliases.
+# Keys are uppercase native names (e.g. "CC_TOGGLE"), values are lists of lowercase aliases.
+REMOTE_KEY_ALIASES: dict[str, list[str]] = {
+ "CC_TOGGLE": ["closed_captions", "cc"],
+ "CH_DOWN": ["channel_down"],
+ "CH_PREV": ["previous_channel"],
+ "CH_UP": ["channel_up"],
+ "INPUT_NEXT": ["next_input"],
+ "MUTE_TOGGLE": ["mute", "toggle_mute"],
+ "OK": ["enter", "select"],
+ "PIC_MODE": ["picture_mode"],
+ "PIC_SIZE": ["picture_size"],
+ "POW_OFF": ["off", "power_off"],
+ "POW_ON": ["on", "power_on"],
+ "POW_TOGGLE": ["power", "power_toggle", "toggle_power"],
+ "SEEK_BACK": ["reverse", "rewind"],
+ "SEEK_FWD": ["forward", "fast_forward", "ff"],
+ "VOL_DOWN": ["volume_down"],
+ "VOL_UP": ["volume_up"],
+}
+
+# Invert aliases into {alias: native_key} for O(1) lookup
+_ALIAS_LOOKUP: dict[str, str] = {
+ alias: key for key, aliases in REMOTE_KEY_ALIASES.items() for alias in aliases
+}
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -49,7 +75,12 @@ class VizioRemote(CoordinatorEntity[VizioDeviceCoordinator], RemoteEntity):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)})
self._device = coordinator.device
valid_keys = set(self._device.get_remote_keys_list())
+ # Map lowercased native keys to their original uppercase pyvizio names
self._command_map: dict[str, str] = {key.lower(): key for key in valid_keys}
+ # Add aliases only for native keys this device actually supports
+ for alias, target in _ALIAS_LOOKUP.items():
+ if target in valid_keys:
+ self._command_map[alias] = target
@property
@override
diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py
index bf191e5b6c6f..41b60a6bd823 100644
--- a/homeassistant/components/waqi/__init__.py
+++ b/homeassistant/components/waqi/__init__.py
@@ -14,7 +14,7 @@ from homeassistant.helpers import (
entity_registry as er,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import CONF_STATION_NUMBER, DOMAIN, SUBENTRY_TYPE_STATION
from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator
@@ -126,10 +126,10 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
)
if device is not None:
- # Device and entity registries don't update the disabled_by flag when
- # moving a device or entity from one config entry to another, so we
- # need to do it manually.
- device_disabled_by = device.disabled_by
+ # The device registry will set the disabled_by flag to None when
+ # moving a device disabled by CONFIG_ENTRY to an enabled config
+ # entry, but we want to set it to USER instead.
+ device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
and not all_disabled
@@ -138,20 +138,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device_registry.async_update_device(
device.id,
disabled_by=device_disabled_by,
- add_config_subentry_id=subentry.subentry_id,
- add_config_entry_id=parent_entry.entry_id,
+ new_config_entry_id=parent_entry.entry_id,
+ new_config_subentry_id=subentry.subentry_id,
)
- if parent_entry.entry_id != entry.entry_id:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- )
- else:
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- remove_config_subentry_id=None,
- )
if parent_entry.entry_id != entry.entry_id:
await hass.config_entries.async_remove(entry.entry_id)
diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py
index bbd310bece50..cb78e17031d0 100644
--- a/homeassistant/components/watttime/sensor.py
+++ b/homeassistant/components/watttime/sensor.py
@@ -9,10 +9,11 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
CONF_SHOW_ON_MAP,
PERCENTAGE,
+ EntityStateAttribute,
UnitOfMass,
)
from homeassistant.core import HomeAssistant
@@ -97,11 +98,11 @@ class RealtimeEmissionsSensor(CoordinatorEntity[WattTimeCoordinator], SensorEnti
# Conversely, we can hide the location on the map by using other keys, like
# "lati" and "long".
if self._entry.options.get(CONF_SHOW_ON_MAP) is not False:
- attrs[ATTR_LATITUDE] = self._entry.data[ATTR_LATITUDE]
- attrs[ATTR_LONGITUDE] = self._entry.data[ATTR_LONGITUDE]
+ attrs[EntityStateAttribute.LATITUDE] = self._entry.data[CONF_LATITUDE]
+ attrs[EntityStateAttribute.LONGITUDE] = self._entry.data[CONF_LONGITUDE]
else:
- attrs["lati"] = self._entry.data[ATTR_LATITUDE]
- attrs["long"] = self._entry.data[ATTR_LONGITUDE]
+ attrs["lati"] = self._entry.data[CONF_LATITUDE]
+ attrs["long"] = self._entry.data[CONF_LONGITUDE]
return attrs
diff --git a/homeassistant/components/wattwaechter/diagnostics.py b/homeassistant/components/wattwaechter/diagnostics.py
new file mode 100644
index 000000000000..a59cfaa9df29
--- /dev/null
+++ b/homeassistant/components/wattwaechter/diagnostics.py
@@ -0,0 +1,53 @@
+"""Diagnostics support for the WattWächter Plus integration."""
+
+from dataclasses import asdict
+from typing import Any
+
+from aio_wattwaechter import (
+ WattwaechterAuthenticationError,
+ WattwaechterConnectionError,
+)
+from aio_wattwaechter.models import SystemInfo
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_MAC, CONF_TOKEN
+from homeassistant.core import HomeAssistant
+
+from .coordinator import WattwaechterConfigEntry
+
+# The device exposes network identifiers as system info values; redact the
+# credential and hardware/network identifiers. Local IPs are kept for support.
+TO_REDACT = {CONF_TOKEN, CONF_MAC, "ssid", "mac_address", "mdns_name"}
+
+
+def _flatten_system(system: SystemInfo) -> dict[str, dict[str, Any]]:
+ """Flatten system info sections into {section: {name: value}} mappings."""
+ return {
+ section: {entry["name"]: entry["value"] for entry in entries}
+ for section, entries in asdict(system).items()
+ }
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: WattwaechterConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator = entry.runtime_data
+
+ # System info is only needed on demand here, so it is fetched directly
+ # instead of in the update loop to avoid coupling meter sensor
+ # availability to it. Failure still yields the config and meter data.
+ system: dict[str, dict[str, Any]] | None = None
+ try:
+ system = _flatten_system(await coordinator.client.system_info())
+ except WattwaechterConnectionError, WattwaechterAuthenticationError:
+ system = None
+
+ return async_redact_data(
+ {
+ "config_entry": dict(entry.data),
+ "meter": asdict(coordinator.data),
+ "system": system,
+ },
+ TO_REDACT,
+ )
diff --git a/homeassistant/components/wattwaechter/quality_scale.yaml b/homeassistant/components/wattwaechter/quality_scale.yaml
index 247726f5ade8..a831a42304cf 100644
--- a/homeassistant/components/wattwaechter/quality_scale.yaml
+++ b/homeassistant/components/wattwaechter/quality_scale.yaml
@@ -49,7 +49,7 @@ rules:
# Gold
devices: done
- diagnostics: todo
+ diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
diff --git a/homeassistant/components/weatherflow/icons.json b/homeassistant/components/weatherflow/icons.json
index 6b691f41c9b3..3d16c457cc44 100644
--- a/homeassistant/components/weatherflow/icons.json
+++ b/homeassistant/components/weatherflow/icons.json
@@ -15,6 +15,15 @@
"lightning_count": {
"default": "mdi:lightning-bolt"
},
+ "lightning_strike_last_distance": {
+ "default": "mdi:lightning-bolt"
+ },
+ "lightning_strike_last_energy": {
+ "default": "mdi:lightning-bolt"
+ },
+ "lightning_strike_last_epoch": {
+ "default": "mdi:lightning-bolt"
+ },
"precipitation_type": {
"default": "mdi:weather-rainy"
},
diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py
index 46af3699298f..d83eda960baa 100644
--- a/homeassistant/components/weatherflow/sensor.py
+++ b/homeassistant/components/weatherflow/sensor.py
@@ -10,6 +10,7 @@ from pyweatherflowudp.const import EVENT_RAPID_WIND
from pyweatherflowudp.device import (
EVENT_OBSERVATION,
EVENT_STATUS_UPDATE,
+ EVENT_STRIKE,
WeatherFlowDevice,
WeatherFlowSensorDevice,
)
@@ -60,12 +61,13 @@ class WeatherFlowSensorEntityDescription(SensorEntityDescription):
raw_data_conv_fn: Callable[[Any], datetime | StateType]
+ device_attr: str | None = None
event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION])
imperial_suggested_unit: str | None = None
def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType:
"""Return the parsed sensor value."""
- if (raw_sensor_data := getattr(device, self.key)) is None:
+ if (raw_sensor_data := getattr(device, self.device_attr or self.key)) is None:
return None
return self.raw_data_conv_fn(raw_sensor_data)
@@ -153,6 +155,33 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
raw_data_conv_fn=lambda raw_data: raw_data,
),
+ WeatherFlowSensorEntityDescription(
+ key="lightning_strike_last_distance",
+ device_attr="last_lightning_strike_event",
+ translation_key="lightning_strike_last_distance",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.DISTANCE,
+ native_unit_of_measurement=UnitOfLength.KILOMETERS,
+ suggested_display_precision=2,
+ event_subscriptions=[EVENT_STRIKE],
+ raw_data_conv_fn=lambda raw_data: raw_data.distance.magnitude,
+ ),
+ WeatherFlowSensorEntityDescription(
+ key="lightning_strike_last_energy",
+ device_attr="last_lightning_strike_event",
+ translation_key="lightning_strike_last_energy",
+ state_class=SensorStateClass.MEASUREMENT,
+ event_subscriptions=[EVENT_STRIKE],
+ raw_data_conv_fn=lambda raw_data: raw_data.energy,
+ ),
+ WeatherFlowSensorEntityDescription(
+ key="lightning_strike_last_epoch",
+ device_attr="last_lightning_strike_event",
+ translation_key="lightning_strike_last_epoch",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ event_subscriptions=[EVENT_STRIKE],
+ raw_data_conv_fn=lambda raw_data: raw_data.timestamp,
+ ),
WeatherFlowSensorEntityDescription(
key="precipitation_type",
translation_key="precipitation_type",
@@ -310,7 +339,7 @@ async def async_setup_entry(
is_metric=(hass.config.units == METRIC_SYSTEM),
)
for description in SENSORS
- if hasattr(device, description.key)
+ if hasattr(device, description.device_attr or description.key)
]
async_add_entities(sensors)
diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json
index d2146e883703..eb5621317094 100644
--- a/homeassistant/components/weatherflow/strings.json
+++ b/homeassistant/components/weatherflow/strings.json
@@ -48,6 +48,15 @@
"lightning_count": {
"name": "Lightning count"
},
+ "lightning_strike_last_distance": {
+ "name": "Lightning last distance"
+ },
+ "lightning_strike_last_energy": {
+ "name": "Lightning last energy"
+ },
+ "lightning_strike_last_epoch": {
+ "name": "Lightning last strike"
+ },
"precipitation_type": {
"name": "Precipitation type",
"state": {
diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py
index d9860bdb0fe5..5a02f76d163d 100644
--- a/homeassistant/components/weatherflow_cloud/__init__.py
+++ b/homeassistant/components/weatherflow_cloud/__init__.py
@@ -4,10 +4,13 @@ import asyncio
from weatherflow4py.api import WeatherFlowRestAPI
from weatherflow4py.ws import WeatherFlowWebsocketAPI
+from websockets.exceptions import WebSocketException
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.util.ssl import client_context
from .const import LOGGER
from .coordinator import (
@@ -67,11 +70,26 @@ async def async_setup_entry(
stations=stations,
)
- # Run setup method
- await asyncio.gather(
- websocket_wind_coordinator.async_setup(),
- websocket_observation_coordinator.async_setup(),
- )
+ async def _async_disconnect_websocket() -> None:
+ """Disconnect the WeatherFlow websocket."""
+ await websocket_api.stop_all_listeners()
+ await websocket_api.close()
+
+ # Connect once because both websocket coordinators share this API instance.
+ try:
+ await websocket_api.connect(client_context())
+ except (OSError, WebSocketException) as err:
+ raise ConfigEntryNotReady("Error connecting to WeatherFlow websocket") from err
+
+ entry.async_on_unload(_async_disconnect_websocket)
+
+ try:
+ await asyncio.gather(
+ websocket_wind_coordinator.async_setup(),
+ websocket_observation_coordinator.async_setup(),
+ )
+ except (OSError, WebSocketException) as err:
+ raise ConfigEntryNotReady("Error setting up WeatherFlow websocket") from err
entry.runtime_data = WeatherFlowCoordinators(
rest_data_coordinator,
@@ -80,14 +98,6 @@ async def async_setup_entry(
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- # Websocket disconnect handler
- async def _async_disconnect_websocket() -> None:
- await websocket_api.stop_all_listeners()
- await websocket_api.close()
-
- # Register a websocket shutdown handler
- entry.async_on_unload(_async_disconnect_websocket)
-
return True
diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py
index 609c00899e98..448d7096aa31 100644
--- a/homeassistant/components/weatherflow_cloud/coordinator.py
+++ b/homeassistant/components/weatherflow_cloud/coordinator.py
@@ -26,7 +26,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util.ssl import client_context
from .const import DOMAIN, LOGGER
@@ -148,8 +147,7 @@ class BaseWebsocketCoordinator[T](BaseWeatherFlowCoordinator[dict[int, T | None]
}
async def async_setup(self) -> None:
- """Set up the websocket connection."""
- await self.websocket_api.connect(client_context())
+ """Register callbacks and subscribe to device messages."""
self.websocket_api.register_callback(
message_type=self._event_type,
callback=self._handle_websocket_message,
diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py
index 5907865aa091..fdced86518b8 100644
--- a/homeassistant/components/webostv/media_player.py
+++ b/homeassistant/components/webostv/media_player.py
@@ -19,7 +19,7 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
-from homeassistant.const import ATTR_SUPPORTED_FEATURES
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant, ServiceResponse
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -155,7 +155,8 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
):
self._supported_features = (
state.attributes.get(
- ATTR_SUPPORTED_FEATURES, MediaPlayerEntityFeature(0)
+ EntityStateAttribute.SUPPORTED_FEATURES,
+ MediaPlayerEntityFeature(0),
)
& ~MediaPlayerEntityFeature.TURN_ON
)
diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py
index 13f856e66674..a6d3b54f4271 100644
--- a/homeassistant/components/websocket_api/messages.py
+++ b/homeassistant/components/websocket_api/messages.py
@@ -131,7 +131,7 @@ def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes:
"""
return b"".join(
(
- _partial_cached_event_message(event)[:-1],
+ _partial_cached_event_message(event),
b',"id":',
message_id_as_bytes,
b"}",
@@ -143,13 +143,14 @@ def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes:
def _partial_cached_event_message(event: Event) -> bytes:
"""Cache and serialize the event to json.
- The message is constructed without the id which appended
- in cached_event_message.
+ The message is cached without the trailing "}" and without the id, both of
+ which are appended in cached_event_message. Trimming here means the slice
+ happens once per event instead of once per subscriber.
"""
return (
_message_to_json_bytes_or_none({"type": "event", "event": event.json_fragment})
or INVALID_JSON_PARTIAL_MESSAGE
- )
+ )[:-1]
def cached_state_diff_message(
@@ -165,7 +166,7 @@ def cached_state_diff_message(
"""
return b"".join(
(
- _partial_cached_state_diff_message(event)[:-1],
+ _partial_cached_state_diff_message(event),
b',"id":',
message_id_as_bytes,
b"}",
@@ -177,15 +178,16 @@ def cached_state_diff_message(
def _partial_cached_state_diff_message(event: Event[EventStateChangedData]) -> bytes:
"""Cache and serialize the event to json.
- The message is constructed without the id which
- will be appended in cached_state_diff_message
+ The message is cached without the trailing "}" and without the id, both of
+ which are appended in cached_state_diff_message. Trimming here means the
+ slice happens once per event instead of once per subscriber.
"""
return (
_message_to_json_bytes_or_none(
{"type": "event", "event": _state_diff_event(event)}
)
or INVALID_JSON_PARTIAL_MESSAGE
- )
+ )[:-1]
def _state_diff_event(
diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py
index 1cdb875549c6..2724a87d3079 100644
--- a/homeassistant/components/whirlpool/__init__.py
+++ b/homeassistant/components/whirlpool/__init__.py
@@ -21,6 +21,8 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
+ Platform.LIGHT,
+ Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
]
diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py
index eb6a5759b7fc..ec98253e9716 100644
--- a/homeassistant/components/whirlpool/entity.py
+++ b/homeassistant/components/whirlpool/entity.py
@@ -75,6 +75,15 @@ class WhirlpoolOvenEntity(WhirlpoolEntity):
_appliance: Oven
+ @staticmethod
+ def cavity_suffix(oven: Oven, cavity: OvenCavity) -> str:
+ """Return the unique-id and translation-key suffix for an oven cavity."""
+ if oven.get_oven_cavity_exists(
+ OvenCavity.Upper
+ ) and oven.get_oven_cavity_exists(OvenCavity.Lower):
+ return "_upper" if cavity == OvenCavity.Upper else "_lower"
+ return ""
+
def __init__(
self,
appliance: Oven,
@@ -84,14 +93,7 @@ class WhirlpoolOvenEntity(WhirlpoolEntity):
) -> None:
"""Initialize the entity."""
self.cavity = cavity
- cavity_suffix = ""
- if appliance.get_oven_cavity_exists(
- OvenCavity.Upper
- ) and appliance.get_oven_cavity_exists(OvenCavity.Lower):
- if cavity == OvenCavity.Upper:
- cavity_suffix = "_upper"
- elif cavity == OvenCavity.Lower:
- cavity_suffix = "_lower"
+ cavity_suffix = self.cavity_suffix(appliance, cavity)
super().__init__(
appliance, unique_id_suffix=f"{unique_id_suffix}{cavity_suffix}"
)
diff --git a/homeassistant/components/whirlpool/light.py b/homeassistant/components/whirlpool/light.py
new file mode 100644
index 000000000000..3314c70b39da
--- /dev/null
+++ b/homeassistant/components/whirlpool/light.py
@@ -0,0 +1,62 @@
+"""Light platform for the Whirlpool Appliances integration."""
+
+from typing import Any, override
+
+from whirlpool.oven import Cavity as OvenCavity, Oven
+
+from homeassistant.components.light import ColorMode, LightEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import WhirlpoolConfigEntry
+from .entity import WhirlpoolOvenEntity
+
+PARALLEL_UPDATES = 1
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: WhirlpoolConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the light platform."""
+ appliances_manager = config_entry.runtime_data
+ async_add_entities(
+ WhirlpoolOvenLight(oven, cavity)
+ for oven in appliances_manager.ovens
+ for cavity in (OvenCavity.Upper, OvenCavity.Lower)
+ if oven.get_oven_cavity_exists(cavity)
+ )
+
+
+class WhirlpoolOvenLight(WhirlpoolOvenEntity, LightEntity):
+ """Light for an oven cavity."""
+
+ _appliance: Oven
+
+ _attr_color_mode = ColorMode.ONOFF
+ _attr_supported_color_modes = {ColorMode.ONOFF}
+
+ def __init__(self, appliance: Oven, cavity: OvenCavity) -> None:
+ """Initialize the oven light."""
+ super().__init__(appliance, cavity, "oven_light", "-light")
+
+ @property
+ @override
+ def is_on(self) -> bool | None:
+ """Return whether the light is on."""
+ return self._appliance.get_light(self.cavity)
+
+ @override
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the light on."""
+ WhirlpoolOvenLight._check_service_request(
+ await self._appliance.set_light(True, self.cavity)
+ )
+
+ @override
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the light off."""
+ WhirlpoolOvenLight._check_service_request(
+ await self._appliance.set_light(False, self.cavity)
+ )
diff --git a/homeassistant/components/whirlpool/number.py b/homeassistant/components/whirlpool/number.py
new file mode 100644
index 000000000000..a76c44014504
--- /dev/null
+++ b/homeassistant/components/whirlpool/number.py
@@ -0,0 +1,79 @@
+"""Number platform for the Whirlpool Appliances integration."""
+
+from typing import override
+
+from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven
+
+from homeassistant.components.number import NumberDeviceClass, NumberEntity
+from homeassistant.const import UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import WhirlpoolConfigEntry
+from .const import DOMAIN
+from .entity import WhirlpoolOvenEntity
+
+PARALLEL_UPDATES = 1
+
+# Oven target temperatures are handled in Celsius. The appliance accepts
+# tenth-of-a-degree values, so a 1-degree step gives fine manual control while
+# automations can still set any value Home Assistant passes through.
+OVEN_MIN_TEMP = 30
+OVEN_MAX_TEMP = 290
+OVEN_TEMP_STEP = 1
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: WhirlpoolConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the number platform."""
+ appliances_manager = config_entry.runtime_data
+ async_add_entities(
+ WhirlpoolOvenTargetTemperature(oven, cavity)
+ for oven in appliances_manager.ovens
+ for cavity in (OvenCavity.Upper, OvenCavity.Lower)
+ if oven.get_oven_cavity_exists(cavity)
+ )
+
+
+class WhirlpoolOvenTargetTemperature(WhirlpoolOvenEntity, NumberEntity):
+ """Settable target temperature for an oven cavity."""
+
+ _attr_device_class = NumberDeviceClass.TEMPERATURE
+ _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
+ _attr_native_min_value = OVEN_MIN_TEMP
+ _attr_native_max_value = OVEN_MAX_TEMP
+ _attr_native_step = OVEN_TEMP_STEP
+
+ def __init__(self, appliance: Oven, cavity: OvenCavity) -> None:
+ """Initialize the oven target temperature number."""
+ super().__init__(
+ appliance, cavity, "oven_target_temperature", "-target_temperature"
+ )
+
+ @override
+ @property
+ def native_value(self) -> float | None:
+ """Return the current target temperature."""
+ return self._appliance.get_target_temp(self.cavity)
+
+ @override
+ async def async_set_native_value(self, value: float) -> None:
+ """Set a new target temperature, keeping the current cook mode."""
+ mode = self._appliance.get_cook_mode(self.cavity)
+ if mode is None or mode == CookMode.Standby:
+ mode = CookMode.Bake
+ try:
+ WhirlpoolOvenTargetTemperature._check_service_request(
+ await self._appliance.set_cook(
+ target_temp=value, mode=mode, cavity=self.cavity
+ )
+ )
+ except ValueError as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_value_set",
+ ) from err
diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml
index 2f75dd42e1aa..1a444ee0f4e5 100644
--- a/homeassistant/components/whirlpool/quality_scale.yaml
+++ b/homeassistant/components/whirlpool/quality_scale.yaml
@@ -74,9 +74,7 @@ rules:
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
+ repair-issues: done
stale-devices: todo
# Platinum
diff --git a/homeassistant/components/whirlpool/select.py b/homeassistant/components/whirlpool/select.py
index 9bac108976a8..6df32716ab69 100644
--- a/homeassistant/components/whirlpool/select.py
+++ b/homeassistant/components/whirlpool/select.py
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from typing import Final, override
from whirlpool.appliance import Appliance
+from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import UnitOfTemperature
@@ -14,10 +15,26 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WhirlpoolConfigEntry
from .const import DOMAIN
-from .entity import WhirlpoolEntity
+from .entity import WhirlpoolEntity, WhirlpoolOvenEntity
PARALLEL_UPDATES = 1
+OVEN_COOK_MODES: Final[dict[CookMode, str]] = {
+ CookMode.Standby: "standby",
+ CookMode.Bake: "bake",
+ CookMode.ConvectBake: "convection_bake",
+ CookMode.Broil: "broil",
+ CookMode.ConvectBroil: "convection_broil",
+ CookMode.ConvectRoast: "convection_roast",
+ CookMode.KeepWarm: "keep_warm",
+ CookMode.AirFry: "air_fry",
+}
+OPTION_TO_OVEN_COOK_MODE: Final = {v: k for k, v in OVEN_COOK_MODES.items()}
+
+# Target temperature (Celsius) used when a mode is selected while the oven is
+# idle and has no target set yet.
+DEFAULT_OVEN_TEMP = 175
+
@dataclass(frozen=True, kw_only=True)
class WhirlpoolSelectDescription(SelectEntityDescription):
@@ -49,11 +66,18 @@ async def async_setup_entry(
"""Set up the select platform."""
appliances_manager = config_entry.runtime_data
- async_add_entities(
+ entities: list[SelectEntity] = [
WhirlpoolSelectEntity(refrigerator, description)
for refrigerator in appliances_manager.refrigerators
for description in REFRIGERATOR_DESCRIPTIONS
+ ]
+ entities.extend(
+ WhirlpoolOvenCookModeSelect(oven, cavity)
+ for oven in appliances_manager.ovens
+ for cavity in (OvenCavity.Upper, OvenCavity.Lower)
+ if oven.get_oven_cavity_exists(cavity)
)
+ async_add_entities(entities)
class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity):
@@ -84,3 +108,44 @@ class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity):
translation_domain=DOMAIN,
translation_key="invalid_value_set",
) from err
+
+
+class WhirlpoolOvenCookModeSelect(WhirlpoolOvenEntity, SelectEntity):
+ """Settable cook mode for an oven cavity."""
+
+ _attr_options = list(OVEN_COOK_MODES.values())
+
+ def __init__(self, appliance: Oven, cavity: OvenCavity) -> None:
+ """Initialize the oven cook mode select."""
+ super().__init__(appliance, cavity, "oven_cook_mode", "-cook_mode")
+
+ @override
+ @property
+ def current_option(self) -> str | None:
+ """Return the current cook mode, if it is a selectable one."""
+ return OVEN_COOK_MODES.get(self._appliance.get_cook_mode(self.cavity))
+
+ @override
+ async def async_select_option(self, option: str) -> None:
+ """Set the cook mode, keeping the current/last target temperature."""
+ mode = OPTION_TO_OVEN_COOK_MODE[option]
+ try:
+ if mode == CookMode.Standby:
+ # Standby is the idle state: the oven reaches it by cancelling
+ # the current cook, not by starting a "standby" cook.
+ result = await self._appliance.stop_cook(self.cavity)
+ else:
+ target = self._appliance.get_target_temp(self.cavity)
+ if target is None:
+ target = DEFAULT_OVEN_TEMP
+ result = await self._appliance.set_cook(
+ target_temp=target,
+ mode=mode,
+ cavity=self.cavity,
+ )
+ WhirlpoolOvenCookModeSelect._check_service_request(result)
+ except ValueError as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_value_set",
+ ) from err
diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py
index e7df831bb7d7..14c695db99fb 100644
--- a/homeassistant/components/whirlpool/sensor.py
+++ b/homeassistant/components/whirlpool/sensor.py
@@ -23,14 +23,16 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import UnitOfTemperature
+from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from . import WhirlpoolConfigEntry
from .entity import WhirlpoolEntity, WhirlpoolOvenEntity
+from .util import deprecate_entity
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(minutes=5)
@@ -256,6 +258,34 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = (
),
)
+# Sensors replaced by more capable entities (select and number respectively).
+DEPRECATED_OVEN_SENSOR_KEYS = ("oven_cook_mode", "oven_target_temperature")
+
+
+def _build_oven_cavity_sensors(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ oven: Oven,
+ cavity: OvenCavity,
+) -> list[SensorEntity]:
+ """Build the sensors for a single oven cavity, handling deprecations."""
+ suffix = WhirlpoolOvenEntity.cavity_suffix(oven, cavity)
+ sensors: list[SensorEntity] = []
+ for description in OVEN_CAVITY_SENSORS:
+ # The oven cook mode and target temperature sensors have been replaced
+ # by select and number entities respectively.
+ if description.key in DEPRECATED_OVEN_SENSOR_KEYS and not deprecate_entity(
+ hass,
+ entity_registry,
+ platform_domain=Platform.SENSOR,
+ entity_unique_id=f"{oven.said}-{description.key}{suffix}",
+ issue_id=f"deprecated_{description.key}_{oven.said}{suffix}",
+ translation_key=f"deprecated_{description.key}",
+ ):
+ continue
+ sensors.append(WhirlpoolOvenCavitySensor(oven, cavity, description))
+ return sensors
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -289,18 +319,13 @@ async def async_setup_entry(
for description in WASHER_DRYER_TIME_SENSORS
]
- oven_upper_cavity_sensors = [
- WhirlpoolOvenCavitySensor(oven, OvenCavity.Upper, description)
+ entity_registry = er.async_get(hass)
+ oven_cavity_sensors = [
+ sensor
for oven in appliances_manager.ovens
- if oven.get_oven_cavity_exists(OvenCavity.Upper)
- for description in OVEN_CAVITY_SENSORS
- ]
-
- oven_lower_cavity_sensors = [
- WhirlpoolOvenCavitySensor(oven, OvenCavity.Lower, description)
- for oven in appliances_manager.ovens
- if oven.get_oven_cavity_exists(OvenCavity.Lower)
- for description in OVEN_CAVITY_SENSORS
+ for cavity in (OvenCavity.Upper, OvenCavity.Lower)
+ if oven.get_oven_cavity_exists(cavity)
+ for sensor in _build_oven_cavity_sensors(hass, entity_registry, oven, cavity)
]
async_add_entities(
@@ -309,8 +334,7 @@ async def async_setup_entry(
*washer_time_sensors,
*dryer_sensors,
*dryer_time_sensors,
- *oven_upper_cavity_sensors,
- *oven_lower_cavity_sensors,
+ *oven_cavity_sensors,
]
)
@@ -332,29 +356,29 @@ class WhirlpoolSensor(WhirlpoolEntity, SensorEntity):
return self.entity_description.value_fn(self._appliance)
-class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC):
- """Abstract base class for Whirlpool washer/dryer time sensors."""
+class WhirlpoolTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC):
+ """Abstract base class for Whirlpool end-time timestamp sensors."""
_attr_should_poll = True
- _appliance: Washer | Dryer
- def __init__(
- self, appliance: Washer | Dryer, description: SensorEntityDescription
- ) -> None:
- """Initialize the washer/dryer sensor."""
- super().__init__(appliance, unique_id_suffix=f"-{description.key}")
- self.entity_description = description
+ def __init__(self, appliance: Appliance, unique_id_suffix: str) -> None:
+ """Initialize the time sensor."""
+ super().__init__(appliance, unique_id_suffix=unique_id_suffix)
self._running: bool | None = None
self._value: datetime | None = None
@abstractmethod
- def _is_machine_state_finished(self) -> bool:
- """Return true if the machine is in a finished state."""
+ def _is_finished(self) -> bool:
+ """Return true if the timer/cycle is in a finished state."""
@abstractmethod
- def _is_machine_state_running(self) -> bool:
- """Return true if the machine is in a running state."""
+ def _is_running(self) -> bool:
+ """Return true if the timer/cycle is in a running state."""
+
+ @abstractmethod
+ def _get_seconds_remaining(self) -> int:
+ """Return the number of seconds remaining."""
@override
async def async_added_to_hass(self) -> None:
@@ -368,21 +392,19 @@ class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC):
"""Update status of Whirlpool."""
await self._appliance.fetch_data()
- @override
@property
+ @override
def native_value(self) -> datetime | None:
"""Calculate the time stamp for completion."""
now = utcnow()
- if self._is_machine_state_finished() and self._running:
+ if self._is_finished() and self._running:
self._running = False
self._value = now
- if self._is_machine_state_running():
+ if self._is_running():
self._running = True
- new_timestamp = now + timedelta(
- seconds=self._appliance.get_time_remaining()
- )
+ new_timestamp = now + timedelta(seconds=self._get_seconds_remaining())
if self._value is None or (
isinstance(self._value, datetime)
and abs(new_timestamp - self._value) > timedelta(seconds=60)
@@ -391,45 +413,59 @@ class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC):
return self._value
-class WasherTimeSensor(WasherDryerTimeSensorBase):
+class WasherTimeSensor(WhirlpoolTimeSensorBase):
"""A timestamp class for Whirlpool washers."""
_appliance: Washer
+ def __init__(self, appliance: Washer, description: SensorEntityDescription) -> None:
+ """Initialize the washer sensor."""
+ super().__init__(appliance, unique_id_suffix=f"-{description.key}")
+ self.entity_description = description
+
@override
- def _is_machine_state_finished(self) -> bool:
- """Return true if the machine is in a finished state."""
+ def _is_finished(self) -> bool:
return self._appliance.get_machine_state() in {
WasherMachineState.Complete,
WasherMachineState.Standby,
}
@override
- def _is_machine_state_running(self) -> bool:
- """Return true if the machine is in a running state."""
+ def _is_running(self) -> bool:
return (
self._appliance.get_machine_state() is WasherMachineState.RunningMainCycle
)
+ @override
+ def _get_seconds_remaining(self) -> int:
+ return self._appliance.get_time_remaining()
-class DryerTimeSensor(WasherDryerTimeSensorBase):
+
+class DryerTimeSensor(WhirlpoolTimeSensorBase):
"""A timestamp class for Whirlpool dryers."""
_appliance: Dryer
+ def __init__(self, appliance: Dryer, description: SensorEntityDescription) -> None:
+ """Initialize the dryer sensor."""
+ super().__init__(appliance, unique_id_suffix=f"-{description.key}")
+ self.entity_description = description
+
@override
- def _is_machine_state_finished(self) -> bool:
- """Return true if the machine is in a finished state."""
+ def _is_finished(self) -> bool:
return self._appliance.get_machine_state() in {
DryerMachineState.Complete,
DryerMachineState.Standby,
}
@override
- def _is_machine_state_running(self) -> bool:
- """Return true if the machine is in a running state."""
+ def _is_running(self) -> bool:
return self._appliance.get_machine_state() is DryerMachineState.RunningMainCycle
+ @override
+ def _get_seconds_remaining(self) -> int:
+ return self._appliance.get_time_remaining()
+
class WhirlpoolOvenCavitySensor(WhirlpoolOvenEntity, SensorEntity):
"""A class for Whirlpool oven cavity sensors."""
diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json
index 2a831caf8028..75a70e8e89b4 100644
--- a/homeassistant/components/whirlpool/strings.json
+++ b/homeassistant/components/whirlpool/strings.json
@@ -57,7 +57,68 @@
"name": "Upper oven stop"
}
},
+ "light": {
+ "oven_light": {
+ "name": "Light"
+ },
+ "oven_light_lower": {
+ "name": "Lower oven light"
+ },
+ "oven_light_upper": {
+ "name": "Upper oven light"
+ }
+ },
+ "number": {
+ "oven_target_temperature": {
+ "name": "Target temperature"
+ },
+ "oven_target_temperature_lower": {
+ "name": "Lower oven target temperature"
+ },
+ "oven_target_temperature_upper": {
+ "name": "Upper oven target temperature"
+ }
+ },
"select": {
+ "oven_cook_mode": {
+ "name": "Cook mode",
+ "state": {
+ "air_fry": "Air fry",
+ "bake": "Bake",
+ "broil": "Broil",
+ "convection_bake": "Convection bake",
+ "convection_broil": "Convection broil",
+ "convection_roast": "Convection roast",
+ "keep_warm": "Keep warm",
+ "standby": "[%key:common::state::standby%]"
+ }
+ },
+ "oven_cook_mode_lower": {
+ "name": "Lower oven cook mode",
+ "state": {
+ "air_fry": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::air_fry%]",
+ "bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::bake%]",
+ "broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::broil%]",
+ "convection_bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_bake%]",
+ "convection_broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_broil%]",
+ "convection_roast": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_roast%]",
+ "keep_warm": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::keep_warm%]",
+ "standby": "[%key:common::state::standby%]"
+ }
+ },
+ "oven_cook_mode_upper": {
+ "name": "Upper oven cook mode",
+ "state": {
+ "air_fry": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::air_fry%]",
+ "bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::bake%]",
+ "broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::broil%]",
+ "convection_bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_bake%]",
+ "convection_broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_broil%]",
+ "convection_roast": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_roast%]",
+ "keep_warm": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::keep_warm%]",
+ "standby": "[%key:common::state::standby%]"
+ }
+ },
"refrigerator_temperature_level": {
"name": "Temperature level"
}
@@ -239,5 +300,23 @@
"request_failed": {
"message": "Request failed"
}
+ },
+ "issues": {
+ "deprecated_oven_cook_mode": {
+ "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nUpdate any dashboards, templates, automations or scripts to use the new select entity, then disable `{entity_id}` to have it removed.",
+ "title": "The Whirlpool oven cook mode sensor is deprecated"
+ },
+ "deprecated_oven_cook_mode_scripts": {
+ "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new select entity, then disable `{entity_id}` to have it removed.",
+ "title": "[%key:component::whirlpool::issues::deprecated_oven_cook_mode::title%]"
+ },
+ "deprecated_oven_target_temperature": {
+ "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nUpdate any dashboards, templates, automations or scripts to use the new number entity, then disable `{entity_id}` to have it removed.",
+ "title": "The Whirlpool oven target temperature sensor is deprecated"
+ },
+ "deprecated_oven_target_temperature_scripts": {
+ "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new number entity, then disable `{entity_id}` to have it removed.",
+ "title": "[%key:component::whirlpool::issues::deprecated_oven_target_temperature::title%]"
+ }
}
}
diff --git a/homeassistant/components/whirlpool/util.py b/homeassistant/components/whirlpool/util.py
new file mode 100644
index 000000000000..a2dc9c6a7ebc
--- /dev/null
+++ b/homeassistant/components/whirlpool/util.py
@@ -0,0 +1,101 @@
+"""Utility helpers for the Whirlpool 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
+
+# Version in which deprecated entities will be removed.
+DEPRECATED_REMOVAL_VERSION = "2026.12.0"
+
+
+def deprecate_entity(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ *,
+ platform_domain: str,
+ entity_unique_id: str,
+ issue_id: str,
+ translation_key: str,
+) -> bool:
+ """Handle deprecation of an entity that has been replaced.
+
+ Return True if the deprecated entity should still be set up, which is the
+ case while it exists in the entity registry. A repair issue informs the user
+ about the replacement and the removal date; when the entity is still used by
+ automations or scripts they are listed in the issue. The entity is removed
+ once the user disables it and nothing references it anymore. New
+ installations never create the entity.
+ """
+ entity_id = entity_registry.async_get_entity_id(
+ platform_domain, DOMAIN, entity_unique_id
+ )
+ if entity_id is None:
+ async_delete_issue(hass, DOMAIN, issue_id)
+ return False
+
+ entity_entry = entity_registry.async_get(entity_id)
+ if entity_entry is None:
+ async_delete_issue(hass, DOMAIN, issue_id)
+ return False
+
+ items = _automations_and_scripts_using_entity(hass, entity_registry, entity_id)
+
+ if entity_entry.disabled and not items:
+ entity_registry.async_remove(entity_id)
+ async_delete_issue(hass, DOMAIN, issue_id)
+ return False
+
+ placeholders = {
+ "entity_id": entity_id,
+ "entity_name": entity_entry.name or entity_entry.original_name or entity_id,
+ }
+ if items:
+ translation_key = f"{translation_key}_scripts"
+ placeholders["items"] = "\n".join(items)
+
+ async_create_issue(
+ hass,
+ DOMAIN,
+ issue_id,
+ breaks_in_ha_version=DEPRECATED_REMOVAL_VERSION,
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key=translation_key,
+ translation_placeholders=placeholders,
+ )
+ return True
+
+
+def _automations_and_scripts_using_entity(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ entity_id: str,
+) -> list[str]:
+ """Return markdown list items for 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 []
+
+ items: list[str] = []
+ for integration, used_entities in (
+ ("automation", automations),
+ ("script", scripts),
+ ):
+ for used_entity_id in used_entities:
+ if entry := entity_registry.async_get(used_entity_id):
+ items.append(
+ f"- [{entry.original_name}](/config/{integration}/edit/{entry.unique_id})"
+ )
+ else:
+ items.append(f"- `{used_entity_id}`")
+
+ return items
diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json
index a93652c0e978..83d724c308a4 100644
--- a/homeassistant/components/wiim/manifest.json
+++ b/homeassistant/components/wiim/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["wiim.sdk", "async_upnp_client"],
"quality_scale": "bronze",
- "requirements": ["wiim==0.1.4"],
+ "requirements": ["wiim==0.1.5"],
"zeroconf": ["_linkplay._tcp.local."]
}
diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py
index f7ce8dd9e952..cb392bc228e8 100644
--- a/homeassistant/components/wiim/media_player.py
+++ b/homeassistant/components/wiim/media_player.py
@@ -31,6 +31,10 @@ from homeassistant.components.media_player import (
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
@@ -65,9 +69,15 @@ SUPPORT_WIIM_BASE = (
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.SEEK
+ | MediaPlayerEntityFeature.GROUPING
)
+def _group_member_state_signal(member_udn: str) -> str:
+ """Return the dispatcher signal for a grouped member state refresh."""
+ return f"wiim_group_member_state_{member_udn}"
+
+
def media_player_exception_wrap[
_WiimMediaPlayerEntityT: WiimMediaPlayerEntity,
**_P,
@@ -208,6 +218,44 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
)
return target_device
+ @callback
+ def _async_handle_group_member_state_refresh(self) -> None:
+ """Trigger local follower cache refresh on leader update push."""
+ group_snapshot = self._get_group_snapshot()
+ if group_snapshot.role != WiimGroupRole.FOLLOWER:
+ LOGGER.debug(
+ "Ignoring group member refresh for %s because role is %s",
+ self.entity_id,
+ group_snapshot.role,
+ )
+ return
+
+ LOGGER.debug(
+ "Follower %s received propagated group update from leader %s",
+ self.entity_id,
+ group_snapshot.leader_udn,
+ )
+ self._update_ha_state_from_sdk_cache()
+
+ @callback
+ def _async_propagate_group_state_update(
+ self, group_snapshot: WiimGroupSnapshot
+ ) -> None:
+ """Notify grouped followers to refresh from the leader's latest cache."""
+ if group_snapshot.role != WiimGroupRole.LEADER:
+ return
+
+ for member_udn in group_snapshot.member_udns:
+ if member_udn == self._device.udn:
+ continue
+
+ LOGGER.debug(
+ "Leader %s propagating grouped state refresh to follower %s",
+ self.entity_id,
+ member_udn,
+ )
+ async_dispatcher_send(self.hass, _group_member_state_signal(member_udn))
+
@callback
def _update_ha_state_from_sdk_cache(
self,
@@ -290,6 +338,7 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
if write_state:
self.async_write_ha_state()
+ self._async_propagate_group_state_update(group_snapshot)
@callback
def _handle_sdk_general_device_update(self, device: WiimDevice) -> None:
@@ -475,6 +524,13 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
"""Run when entity is added to Home Assistant."""
await super().async_added_to_hass()
self._wiim_data.entity_id_to_udn_map[self.entity_id] = self._device.udn
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ _group_member_state_signal(self._device.udn),
+ self._async_handle_group_member_state_refresh,
+ )
+ )
LOGGER.debug(
"Added %s (UDN: %s) to entity maps in hass.data",
self.entity_id,
@@ -806,3 +862,61 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
media_content_id,
)
raise BrowseError(f"Invalid browse path: {media_content_id}")
+
+ @media_player_exception_wrap
+ @override
+ async def async_join_players(self, group_members: list[str]) -> None:
+ """Join group_members (entity_ids) to the group led by the current player."""
+ target_device = self._get_command_target_device("join")
+ follower_udns_to_join: list[str] = []
+ for member_entity_id in group_members:
+ if member_entity_id == self.entity_id:
+ LOGGER.debug("Skipping joining self to group: %s", member_entity_id)
+ continue
+
+ follower_udn = self._wiim_data.entity_id_to_udn_map.get(member_entity_id)
+ if follower_udn is None:
+ LOGGER.warning(
+ "Unable to resolve group member entity_id %s to a UDN",
+ member_entity_id,
+ )
+ continue
+
+ if follower_udn == target_device.udn:
+ LOGGER.debug(
+ "Skipping joining command target to its own group: %s",
+ member_entity_id,
+ )
+ continue
+
+ follower_udns_to_join.append(follower_udn)
+
+ if not follower_udns_to_join:
+ LOGGER.debug(
+ "Skipping join for %s because no follower UDNs were resolved from %s",
+ self.entity_id,
+ group_members,
+ )
+ return
+
+ LOGGER.debug(
+ "Player %s (UDN %s) joining follower UDNs: %s from entity_ids: %s",
+ self.entity_id,
+ target_device.udn,
+ follower_udns_to_join,
+ group_members,
+ )
+ await self._wiim_data.controller.async_join_group(
+ target_device.udn, follower_udns_to_join
+ )
+
+ @media_player_exception_wrap
+ @override
+ async def async_unjoin_player(self) -> None:
+ """Remove this player from any group it is currently in."""
+ LOGGER.debug(
+ "Player %s (UDN %s) attempting to unjoin from group",
+ self.entity_id,
+ self._device.udn,
+ )
+ await self._wiim_data.controller.async_ungroup_device(self._device.udn)
diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py
index 520c89e7f399..73b56a8dae35 100644
--- a/homeassistant/components/withings/sensor.py
+++ b/homeassistant/components/withings/sensor.py
@@ -850,17 +850,22 @@ async def async_setup_entry(
if new_devices:
device_registry = dr.async_get(hass)
for device_id in new_devices:
- if device := device_registry.async_get_device({(DOMAIN, device_id)}):
- if any(
- (
- config_entry := hass.config_entries.async_get_entry(
- config_entry_id
- )
+ # The same sub-device can be reported by several config entries, each
+ # owning its own device registry entry. Its sensors share a unique id
+ # across config entries, so only create them if no other loaded config
+ # entry already provides them.
+ if any(
+ (
+ config_entry := hass.config_entries.async_get_entry(
+ device.config_entry_id
)
- and config_entry.state is ConfigEntryState.LOADED
- for config_entry_id in device.config_entries
- ):
- continue
+ )
+ and config_entry.state is ConfigEntryState.LOADED
+ for device in device_registry.devices.get_entries(
+ identifiers={(DOMAIN, device_id)}
+ )
+ ):
+ continue
async_add_entities(
WithingsDeviceSensor(device_coordinator, description, device_id)
for description in DEVICE_SENSORS
@@ -870,11 +875,17 @@ async def async_setup_entry(
if old_devices:
device_registry = dr.async_get(hass)
for device_id in old_devices:
- if device := device_registry.async_get_device({(DOMAIN, device_id)}):
- device_registry.async_update_device(
- device.id, remove_config_entry_id=entry.entry_id
- )
- current_devices.remove(device_id)
+ # Several config entries can share this identifier, each owning its own
+ # device registry entry, so only remove this entry's own device.
+ for device in device_registry.devices.get_entries(
+ identifiers={(DOMAIN, device_id)}
+ ):
+ if device.config_entry_id == entry.entry_id:
+ device_registry.async_update_device(
+ device.id, remove_config_entry_id=entry.entry_id
+ )
+ break
+ current_devices.remove(device_id)
device_coordinator.async_add_listener(_async_device_listener)
diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py
index 2553cf2a9ca1..71c2310a33f5 100644
--- a/homeassistant/components/wmspro/__init__.py
+++ b/homeassistant/components/wmspro/__init__.py
@@ -10,12 +10,13 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, 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.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import STORAGE_DIR
-from homeassistant.helpers.typing import UNDEFINED
+from homeassistant.helpers.typing import UNDEFINED, ConfigType
from .const import DOMAIN, MANUFACTURER
+from .services import async_setup_services
PLATFORMS: list[Platform] = [
Platform.BUTTON,
@@ -28,6 +29,8 @@ PLATFORMS: list[Platform] = [
type WebControlProConfigEntry = ConfigEntry[WebControlPro]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
def _build_storage_config_dir(
hass: HomeAssistant, entry: WebControlProConfigEntry
@@ -36,6 +39,12 @@ def _build_storage_config_dir(
return Path(hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.entry_id}"))
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the component."""
+ async_setup_services(hass)
+ return True
+
+
async def async_setup_entry(
hass: HomeAssistant, entry: WebControlProConfigEntry
) -> bool:
diff --git a/homeassistant/components/wmspro/const.py b/homeassistant/components/wmspro/const.py
index d92534d9e46f..94d15992ab1d 100644
--- a/homeassistant/components/wmspro/const.py
+++ b/homeassistant/components/wmspro/const.py
@@ -7,3 +7,4 @@ ATTRIBUTION = "Data provided by WMS WebControl pro API"
MANUFACTURER = "WAREMA Renkhoff SE"
BRIGHTNESS_SCALE = (1, 100)
+SERVICE_SET_COVER_POSITION_AND_TILT = "set_cover_position_and_tilt"
diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py
index 8b63ca78d030..92d068c8f44b 100644
--- a/homeassistant/components/wmspro/cover.py
+++ b/homeassistant/components/wmspro/cover.py
@@ -206,3 +206,15 @@ class WebControlProSlatRotate(WebControlProSlat):
# with the close position the slat is perpendicular to the ground.
# This position will block the light best.
await action(rotation=action.maxValue)
+
+ async def async_set_cover_position_and_tilt(self, **kwargs: Any) -> None:
+ """Handle the service action call to set cover position and tilt."""
+ action_drive = self._dest.action(self._drive_action_desc)
+ action_list = action_drive.prep(percentage=100 - kwargs[ATTR_POSITION])
+ action_tilt = self._dest.action(self._tilt_action_desc)
+ rotation = percentage_to_ranged_value(
+ (action_tilt.minValue, action_tilt.maxValue),
+ 100 - kwargs[ATTR_TILT_POSITION],
+ )
+ action_list += action_tilt.prep(rotation=rotation)
+ await action_list()
diff --git a/homeassistant/components/wmspro/icons.json b/homeassistant/components/wmspro/icons.json
index d7601026f573..ca5d0810f9ee 100644
--- a/homeassistant/components/wmspro/icons.json
+++ b/homeassistant/components/wmspro/icons.json
@@ -16,5 +16,10 @@
"default": "mdi:rotate-left"
}
}
+ },
+ "services": {
+ "set_cover_position_and_tilt": {
+ "service": "mdi:blinds-horizontal"
+ }
}
}
diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json
index bcdc71e2b336..dbd7b0cae917 100644
--- a/homeassistant/components/wmspro/manifest.json
+++ b/homeassistant/components/wmspro/manifest.json
@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/wmspro",
"integration_type": "hub",
"iot_class": "local_polling",
- "requirements": ["pywmspro==0.4.0"]
+ "requirements": ["pywmspro==0.4.2"]
}
diff --git a/homeassistant/components/wmspro/services.py b/homeassistant/components/wmspro/services.py
new file mode 100644
index 000000000000..389ce0de4304
--- /dev/null
+++ b/homeassistant/components/wmspro/services.py
@@ -0,0 +1,37 @@
+"""Services for WMS WebControl pro."""
+
+import voluptuous as vol
+
+from homeassistant.components.cover import (
+ ATTR_POSITION,
+ ATTR_TILT_POSITION,
+ DOMAIN as COVER_DOMAIN,
+ CoverEntityFeature,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import service
+
+from .const import DOMAIN, SERVICE_SET_COVER_POSITION_AND_TILT
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Set up services for WMS WebControl pro."""
+ service.async_register_platform_entity_service(
+ hass,
+ service_domain=DOMAIN,
+ service_name=SERVICE_SET_COVER_POSITION_AND_TILT,
+ entity_domain=COVER_DOMAIN,
+ func="async_set_cover_position_and_tilt",
+ required_features=[
+ CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_TILT_POSITION
+ ],
+ schema={
+ vol.Required(ATTR_POSITION): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=100)
+ ),
+ vol.Required(ATTR_TILT_POSITION): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=100)
+ ),
+ },
+ )
diff --git a/homeassistant/components/wmspro/services.yaml b/homeassistant/components/wmspro/services.yaml
new file mode 100644
index 000000000000..fd444e5c7697
--- /dev/null
+++ b/homeassistant/components/wmspro/services.yaml
@@ -0,0 +1,25 @@
+set_cover_position_and_tilt:
+ target:
+ entity:
+ integration: wmspro
+ domain: cover
+ supported_features:
+ - - cover.CoverEntityFeature.SET_POSITION
+ - cover.CoverEntityFeature.SET_TILT_POSITION
+ fields:
+ position:
+ required: true
+ selector:
+ number:
+ min: 0
+ max: 100
+ mode: slider
+ unit_of_measurement: "%"
+ tilt_position:
+ required: true
+ selector:
+ number:
+ min: 0
+ max: 100
+ mode: slider
+ unit_of_measurement: "%"
diff --git a/homeassistant/components/wmspro/strings.json b/homeassistant/components/wmspro/strings.json
index 9d0b2c04a4d4..988d20e1efca 100644
--- a/homeassistant/components/wmspro/strings.json
+++ b/homeassistant/components/wmspro/strings.json
@@ -25,7 +25,7 @@
"entity": {
"button": {
"rotation-reset": {
- "name": "Reset Rotation"
+ "name": "Reset rotation"
}
},
"cover": {
@@ -38,14 +38,30 @@
"name": "Rotation"
},
"rotation-max": {
- "name": "Maximum Rotation"
+ "name": "Maximum rotation"
},
"rotation-min": {
- "name": "Minimum Rotation"
+ "name": "Minimum rotation"
},
"rotation-raw": {
- "name": "Raw Rotation"
+ "name": "Raw rotation"
}
}
+ },
+ "services": {
+ "set_cover_position_and_tilt": {
+ "description": "Moves the cover and tilt to the target position simultaneously, preventing cancellation of individual movements.",
+ "fields": {
+ "position": {
+ "description": "Target vertical position. 0 means closed, 100 means fully open.",
+ "name": "Position"
+ },
+ "tilt_position": {
+ "description": "Target tilt position.",
+ "name": "Tilt position"
+ }
+ },
+ "name": "Set cover position and tilt"
+ }
}
}
diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py
index d86047e323dc..1a94fc700194 100644
--- a/homeassistant/components/wolflink/__init__.py
+++ b/homeassistant/components/wolflink/__init__.py
@@ -13,6 +13,7 @@ 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.httpx_client import create_async_httpx_client
+from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .const import DOMAIN, MANUFACTURER
from .coordinator import WolflinkConfigEntry, WolfLinkCoordinator
@@ -171,22 +172,21 @@ def _reattach_device_to_hub(
if device is None:
return
- device_disabled_by = device.disabled_by
- if device_disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY:
+ # The device registry will set the disabled_by flag to None when moving a
+ # device disabled by CONFIG_ENTRY to an enabled config entry, but we want
+ # to set it to USER instead.
+ device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED
+ if (
+ device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
+ and hub_entry.disabled_by is None
+ ):
device_disabled_by = dr.DeviceEntryDisabler.USER
- if source_entry.entry_id != hub_entry.entry_id:
- device_registry.async_update_device(
- device.id,
- disabled_by=device_disabled_by,
- add_config_entry_id=hub_entry.entry_id,
- remove_config_entry_id=source_entry.entry_id,
- )
- else:
- device_registry.async_update_device(
- device.id,
- disabled_by=device_disabled_by,
- )
+ device_registry.async_update_device(
+ device.id,
+ disabled_by=device_disabled_by,
+ new_config_entry_id=hub_entry.entry_id,
+ )
for entity_entry in er.async_entries_for_device(
entity_registry, device.id, include_disabled_entities=True
diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json
index b4df67d61b7c..1d2009138865 100644
--- a/homeassistant/components/workday/manifest.json
+++ b/homeassistant/components/workday/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
- "requirements": ["holidays==0.99"]
+ "requirements": ["holidays==0.100"]
}
diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py
index 2176f89e2fe5..a08772e057e1 100644
--- a/homeassistant/components/wyoming/assist_satellite.py
+++ b/homeassistant/components/wyoming/assist_satellite.py
@@ -110,6 +110,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
self._client: AsyncTcpClient | None = None
self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1)
self._is_pipeline_running = False
+ self._pipeline_error = False
self._pipeline_ended_event = asyncio.Event()
self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
self._pipeline_id: str | None = None
@@ -316,7 +317,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.ERROR:
- # Pipeline error
+ # Pipeline error. Prevents an "always on" satellite from restarting
+ # a failing pipeline in a tight loop (e.g. on a config error).
+ self._pipeline_error = True
if event.data:
self.config_entry.async_create_background_task(
self.hass,
@@ -623,12 +626,24 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
# Clear last wake word detection
wake_word_phrase = None
- if (run_pipeline is not None) and run_pipeline.restart_on_end:
+ if (
+ (run_pipeline is not None)
+ and run_pipeline.restart_on_end
+ and not self._pipeline_error
+ ):
# Automatically restart pipeline.
# Used with "always on" streaming satellites.
self._run_pipeline_once(run_pipeline)
continue
+ if self._pipeline_error:
+ # Don't restart a failing pipeline in a tight loop; wait
+ # for the satellite to request a new run.
+ _LOGGER.debug(
+ "Not restarting pipeline after error; "
+ "waiting for next satellite request"
+ )
+
if client_event_task not in done:
continue
@@ -736,6 +751,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
self._audio_queue = asyncio.Queue()
self._is_pipeline_running = True
+ self._pipeline_error = False
self._pipeline_ended_event.clear()
self.config_entry.async_create_background_task(
self.hass,
diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py
index 368157a22c2b..d9b0766d70eb 100644
--- a/homeassistant/components/xbox/coordinator.py
+++ b/homeassistant/components/xbox/coordinator.py
@@ -127,9 +127,7 @@ class XboxConsolesCoordinator(XboxBaseCoordinator[dict[str, SmartglassConsole]])
and not set(device.identifiers) & identifiers
):
_LOGGER.debug("Removing stale device %s", device.name)
- device_reg.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_reg.async_remove_device(device.id)
return {console.id: console for console in consoles.result}
diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py
index 67a2d1b77c14..690121850773 100644
--- a/homeassistant/components/xiaomi_miio/const.py
+++ b/homeassistant/components/xiaomi_miio/const.py
@@ -276,6 +276,16 @@ SERVICE_SET_EXTRA_FEATURES = "fan_set_extra_features"
SERVICE_SET_DRY = "set_dry"
SERVICE_SET_MOTOR_SPEED = "fan_set_motor_speed"
+# Fan/Humidifier data
+FAN_DATA_KEY = "fan.xiaomi_miio"
+
+# Light data
+LIGHT_DATA_KEY = "light.xiaomi_miio"
+ATTR_SCENE = "scene"
+
+# Switch data
+SWITCH_DATA_KEY = "switch.xiaomi_miio"
+
# Light Services
SERVICE_SET_SCENE = "light_set_scene"
SERVICE_SET_DELAYED_TURN_OFF = "light_set_delayed_turn_off"
diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py
index d08f52fdf56d..e15d13fa81e7 100644
--- a/homeassistant/components/xiaomi_miio/fan.py
+++ b/homeassistant/components/xiaomi_miio/fan.py
@@ -1,7 +1,6 @@
"""Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier."""
from abc import abstractmethod
-import asyncio
import logging
import math
from typing import Any, override
@@ -28,12 +27,10 @@ from miio.integrations.fan.dmaker.fan_miot import FanStatusMiot
from miio.integrations.fan.zhimi.zhimi_miot import (
OperationModeFanZA5 as FanZA5OperationMode,
)
-import voluptuous as vol
from homeassistant.components.fan import FanEntity, FanEntityFeature
-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.const import CONF_DEVICE, CONF_MODEL
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.percentage import (
@@ -43,7 +40,7 @@ from homeassistant.util.percentage import (
from .const import (
CONF_FLOW_TYPE,
- DOMAIN,
+ FAN_DATA_KEY as DATA_KEY,
FEATURE_FLAGS_AIRFRESH,
FEATURE_FLAGS_AIRFRESH_A1,
FEATURE_FLAGS_AIRFRESH_T2017,
@@ -89,16 +86,12 @@ from .const import (
MODELS_FAN_MIIO,
MODELS_FAN_MIOT,
MODELS_PURIFIER_MIOT,
- SERVICE_RESET_FILTER,
- SERVICE_SET_EXTRA_FEATURES,
)
from .entity import XiaomiCoordinatedMiioEntity
-from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry
+from .typing import XiaomiMiioConfigEntry
_LOGGER = logging.getLogger(__name__)
-DATA_KEY = "fan.xiaomi_miio"
-
ATTR_MODE_NATURE = "nature"
ATTR_MODE_NORMAL = "normal"
@@ -108,7 +101,6 @@ ATTR_FAN_LEVEL = "fan_level"
ATTR_SLEEP_TIME = "sleep_time"
ATTR_SLEEP_LEARN_COUNT = "sleep_mode_learn_count"
ATTR_EXTRA_FEATURES = "extra_features"
-ATTR_FEATURES = "features"
ATTR_TURBO_MODE_SUPPORTED = "turbo_mode_supported"
ATTR_SLEEP_MODE = "sleep_mode"
ATTR_USE_TIME = "use_time"
@@ -181,20 +173,6 @@ PRESET_MODES_AIRPURIFIER_V3 = [
PRESET_MODES_AIRFRESH = ["Auto", "Interval"]
PRESET_MODES_AIRFRESH_A1 = ["Auto", "Sleep", "Favorite"]
-AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
-
-SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend(
- {vol.Required(ATTR_FEATURES): cv.positive_int}
-)
-
-SERVICE_TO_METHOD = {
- SERVICE_RESET_FILTER: ServiceMethodDetails(method="async_reset_filter"),
- SERVICE_SET_EXTRA_FEATURES: ServiceMethodDetails(
- method="async_set_extra_features",
- schema=SERVICE_SCHEMA_EXTRA_FEATURES,
- ),
-}
-
FAN_DIRECTIONS_MAP = {
"forward": "right",
"reverse": "left",
@@ -259,40 +237,6 @@ async def async_setup_entry(
entities.append(entity)
- async def async_service_handler(service: ServiceCall) -> None:
- """Map services to methods on XiaomiAirPurifier."""
- method = SERVICE_TO_METHOD[service.service]
- params = {
- key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID
- }
- if entity_ids := service.data.get(ATTR_ENTITY_ID):
- filtered_entities = [
- entity
- for entity in hass.data[DATA_KEY].values()
- if entity.entity_id in entity_ids
- ]
- else:
- filtered_entities = hass.data[DATA_KEY].values()
-
- update_tasks = []
-
- for entity in filtered_entities:
- entity_method = getattr(entity, method.method, None)
- if not entity_method:
- continue
- await entity_method(**params)
- update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True)))
-
- if update_tasks:
- await asyncio.wait(update_tasks)
-
- for air_purifier_service, method in SERVICE_TO_METHOD.items():
- schema = method.schema or AIRPURIFIER_SERVICE_SCHEMA
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, air_purifier_service, async_service_handler, schema=schema
- )
-
async_add_entities(entities)
diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py
index b3d73141209e..35cf33778e50 100644
--- a/homeassistant/components/xiaomi_miio/light.py
+++ b/homeassistant/components/xiaomi_miio/light.py
@@ -1,6 +1,5 @@
"""Support for Xiaomi Philips Lights."""
-import asyncio
import datetime
from datetime import timedelta
from functools import partial
@@ -23,7 +22,6 @@ from miio.gateway.gateway import (
GATEWAY_MODEL_AC_V3,
GatewayException,
)
-import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -32,44 +30,30 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
-from homeassistant.const import (
- ATTR_ENTITY_ID,
- CONF_DEVICE,
- CONF_HOST,
- CONF_MODEL,
- CONF_TOKEN,
-)
-from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import config_validation as cv
+from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util, dt as dt_util
from .const import (
+ ATTR_SCENE,
CONF_FLOW_TYPE,
CONF_GATEWAY,
DOMAIN,
+ LIGHT_DATA_KEY as DATA_KEY,
MODELS_LIGHT_BULB,
MODELS_LIGHT_CEILING,
MODELS_LIGHT_EYECARE,
MODELS_LIGHT_MONO,
MODELS_LIGHT_MOON,
- SERVICE_EYECARE_MODE_OFF,
- SERVICE_EYECARE_MODE_ON,
- SERVICE_NIGHT_LIGHT_MODE_OFF,
- SERVICE_NIGHT_LIGHT_MODE_ON,
- SERVICE_REMINDER_OFF,
- SERVICE_REMINDER_ON,
- SERVICE_SET_DELAYED_TURN_OFF,
- SERVICE_SET_SCENE,
)
from .entity import XiaomiGatewayDevice, XiaomiMiioEntity
-from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry
+from .typing import XiaomiMiioConfigEntry
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Xiaomi Philips Light"
-DATA_KEY = "light.xiaomi_miio"
# The light does not accept cct values < 1
CCT_MIN = 1
@@ -79,9 +63,7 @@ DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS = 4
DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES = 1
SUCCESS = ["ok"]
-ATTR_SCENE = "scene"
ATTR_DELAYED_TURN_OFF = "delayed_turn_off"
-ATTR_TIME_PERIOD = "time_period"
ATTR_NIGHT_LIGHT_MODE = "night_light_mode"
ATTR_AUTOMATIC_COLOR_TEMPERATURE = "automatic_color_temperature"
ATTR_REMINDER = "reminder"
@@ -94,37 +76,6 @@ ATTR_TOTAL_ASSISTANT_SLEEP_TIME = "total_assistant_sleep_time"
ATTR_BAND_SLEEP = "band_sleep"
ATTR_BAND = "band"
-XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
-
-SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend(
- {vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6))}
-)
-
-SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend(
- {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period}
-)
-
-SERVICE_TO_METHOD = {
- SERVICE_SET_DELAYED_TURN_OFF: ServiceMethodDetails(
- method="async_set_delayed_turn_off",
- schema=SERVICE_SCHEMA_SET_DELAYED_TURN_OFF,
- ),
- SERVICE_SET_SCENE: ServiceMethodDetails(
- method="async_set_scene",
- schema=SERVICE_SCHEMA_SET_SCENE,
- ),
- SERVICE_REMINDER_ON: ServiceMethodDetails(method="async_reminder_on"),
- SERVICE_REMINDER_OFF: ServiceMethodDetails(method="async_reminder_off"),
- SERVICE_NIGHT_LIGHT_MODE_ON: ServiceMethodDetails(
- method="async_night_light_mode_on"
- ),
- SERVICE_NIGHT_LIGHT_MODE_OFF: ServiceMethodDetails(
- method="async_night_light_mode_off"
- ),
- SERVICE_EYECARE_MODE_ON: ServiceMethodDetails(method="async_eyecare_mode_on"),
- SERVICE_EYECARE_MODE_OFF: ServiceMethodDetails(method="async_eyecare_mode_off"),
-}
-
async def async_setup_entry(
hass: HomeAssistant,
@@ -212,42 +163,6 @@ async def async_setup_entry(
)
return
- async def async_service_handler(service: ServiceCall) -> None:
- """Map services to methods on Xiaomi Philips Lights."""
- method = SERVICE_TO_METHOD[service.service]
- params = {
- key: value
- for key, value in service.data.items()
- if key != ATTR_ENTITY_ID
- }
- if entity_ids := service.data.get(ATTR_ENTITY_ID):
- target_devices = [
- dev
- for dev in hass.data[DATA_KEY].values()
- if dev.entity_id in entity_ids
- ]
- else:
- target_devices = hass.data[DATA_KEY].values()
-
- update_tasks = []
- for target_device in target_devices:
- if not hasattr(target_device, method.method):
- continue
- await getattr(target_device, method.method)(**params)
- update_tasks.append(
- asyncio.create_task(target_device.async_update_ha_state(True))
- )
-
- if update_tasks:
- await asyncio.wait(update_tasks)
-
- for xiaomi_miio_service, method in SERVICE_TO_METHOD.items():
- schema = method.schema or XIAOMI_MIIO_SERVICE_SCHEMA
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema
- )
-
async_add_entities(entities, update_before_add=True)
diff --git a/homeassistant/components/xiaomi_miio/services.py b/homeassistant/components/xiaomi_miio/services.py
index 97397f9feecb..7ae0140202e8 100644
--- a/homeassistant/components/xiaomi_miio/services.py
+++ b/homeassistant/components/xiaomi_miio/services.py
@@ -1,12 +1,39 @@
"""Xiaomi services."""
+import asyncio
+import logging
+
import voluptuous as vol
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE
+from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv, service
-from .const import DOMAIN
+from .const import (
+ ATTR_SCENE,
+ DOMAIN,
+ FAN_DATA_KEY,
+ LIGHT_DATA_KEY,
+ SERVICE_EYECARE_MODE_OFF,
+ SERVICE_EYECARE_MODE_ON,
+ SERVICE_NIGHT_LIGHT_MODE_OFF,
+ SERVICE_NIGHT_LIGHT_MODE_ON,
+ SERVICE_REMINDER_OFF,
+ SERVICE_REMINDER_ON,
+ SERVICE_RESET_FILTER,
+ SERVICE_SET_DELAYED_TURN_OFF,
+ SERVICE_SET_EXTRA_FEATURES,
+ SERVICE_SET_POWER_MODE,
+ SERVICE_SET_POWER_PRICE,
+ SERVICE_SET_SCENE,
+ SERVICE_SET_WIFI_LED_OFF,
+ SERVICE_SET_WIFI_LED_ON,
+ SWITCH_DATA_KEY,
+)
+from .typing import ServiceMethodDetails
+
+_LOGGER = logging.getLogger(__name__)
ATTR_RC_DURATION = "duration"
ATTR_RC_ROTATION = "rotation"
@@ -23,11 +50,81 @@ SERVICE_CLEAN_SEGMENT = "vacuum_clean_segment"
SERVICE_CLEAN_ZONE = "vacuum_clean_zone"
SERVICE_GOTO = "vacuum_goto"
+# Light Services
+ATTR_TIME_PERIOD = "time_period"
+XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
+SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend(
+ {vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6))}
+)
+SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend(
+ {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period}
+)
+LIGHT_SERVICE_TO_METHOD = {
+ SERVICE_SET_DELAYED_TURN_OFF: ServiceMethodDetails(
+ method="async_set_delayed_turn_off",
+ schema=SERVICE_SCHEMA_SET_DELAYED_TURN_OFF,
+ ),
+ SERVICE_SET_SCENE: ServiceMethodDetails(
+ method="async_set_scene",
+ schema=SERVICE_SCHEMA_SET_SCENE,
+ ),
+ SERVICE_REMINDER_ON: ServiceMethodDetails(method="async_reminder_on"),
+ SERVICE_REMINDER_OFF: ServiceMethodDetails(method="async_reminder_off"),
+ SERVICE_NIGHT_LIGHT_MODE_ON: ServiceMethodDetails(
+ method="async_night_light_mode_on"
+ ),
+ SERVICE_NIGHT_LIGHT_MODE_OFF: ServiceMethodDetails(
+ method="async_night_light_mode_off"
+ ),
+ SERVICE_EYECARE_MODE_ON: ServiceMethodDetails(method="async_eyecare_mode_on"),
+ SERVICE_EYECARE_MODE_OFF: ServiceMethodDetails(method="async_eyecare_mode_off"),
+}
+
+# Switch Services
+ATTR_PRICE = "price"
+SWITCH_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
+SWITCH_SERVICE_SCHEMA_POWER_MODE = SWITCH_SERVICE_SCHEMA.extend(
+ {vol.Required(ATTR_MODE): vol.All(vol.In(["green", "normal"]))}
+)
+SWITCH_SERVICE_SCHEMA_POWER_PRICE = SWITCH_SERVICE_SCHEMA.extend(
+ {vol.Required(ATTR_PRICE): cv.positive_float}
+)
+SWITCH_SERVICE_TO_METHOD = {
+ SERVICE_SET_WIFI_LED_ON: ServiceMethodDetails(method="async_set_wifi_led_on"),
+ SERVICE_SET_WIFI_LED_OFF: ServiceMethodDetails(method="async_set_wifi_led_off"),
+ SERVICE_SET_POWER_MODE: ServiceMethodDetails(
+ method="async_set_power_mode",
+ schema=SWITCH_SERVICE_SCHEMA_POWER_MODE,
+ ),
+ SERVICE_SET_POWER_PRICE: ServiceMethodDetails(
+ method="async_set_power_price",
+ schema=SWITCH_SERVICE_SCHEMA_POWER_PRICE,
+ ),
+}
+
+# Fan Services
+ATTR_FEATURES = "features"
+FAN_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
+FAN_SERVICE_SCHEMA_EXTRA_FEATURES = FAN_SERVICE_SCHEMA.extend(
+ {vol.Required(ATTR_FEATURES): cv.positive_int}
+)
+FAN_SERVICE_TO_METHOD = {
+ SERVICE_RESET_FILTER: ServiceMethodDetails(method="async_reset_filter"),
+ SERVICE_SET_EXTRA_FEATURES: ServiceMethodDetails(
+ method="async_set_extra_features",
+ schema=FAN_SERVICE_SCHEMA_EXTRA_FEATURES,
+ ),
+}
+
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
+ _async_setup_fan_services(hass)
+ _async_setup_light_services(hass)
+ _async_setup_switch_services(hass)
+
# Vacuum Services
service.async_register_platform_entity_service(
hass,
@@ -126,3 +223,115 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema={vol.Required("segments"): vol.Any(vol.Coerce(int), [vol.Coerce(int)])},
func="async_clean_segment",
)
+
+
+def _async_setup_light_services(hass: HomeAssistant) -> None:
+ """Set up Xiaomi Miio light services."""
+ hass.data.setdefault(LIGHT_DATA_KEY, {})
+
+ async def async_service_handler(call: ServiceCall) -> None:
+ """Map services to methods on Xiaomi Philips Lights."""
+ method = LIGHT_SERVICE_TO_METHOD[call.service]
+ params = {
+ key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID
+ }
+ if entity_ids := call.data.get(ATTR_ENTITY_ID):
+ target_devices = [
+ dev
+ for dev in hass.data[LIGHT_DATA_KEY].values()
+ if dev.entity_id in entity_ids
+ ]
+ else:
+ target_devices = hass.data[LIGHT_DATA_KEY].values()
+
+ update_tasks = []
+ for target_device in target_devices:
+ if not hasattr(target_device, method.method):
+ continue
+ await getattr(target_device, method.method)(**params)
+ update_tasks.append(
+ asyncio.create_task(target_device.async_update_ha_state(True))
+ )
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ for xiaomi_miio_service, method in LIGHT_SERVICE_TO_METHOD.items():
+ schema = method.schema or XIAOMI_MIIO_SERVICE_SCHEMA
+ hass.services.async_register(
+ DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema
+ )
+
+
+def _async_setup_switch_services(hass: HomeAssistant) -> None:
+ """Set up Xiaomi Miio switch services."""
+ hass.data.setdefault(SWITCH_DATA_KEY, {})
+
+ async def async_service_handler(call: ServiceCall) -> None:
+ """Map services to methods on XiaomiPlugGenericSwitch."""
+ method = SWITCH_SERVICE_TO_METHOD[call.service]
+ params = {
+ key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID
+ }
+ if entity_ids := call.data.get(ATTR_ENTITY_ID):
+ devices = [
+ device
+ for device in hass.data[SWITCH_DATA_KEY].values()
+ if device.entity_id in entity_ids
+ ]
+ else:
+ devices = hass.data[SWITCH_DATA_KEY].values()
+
+ update_tasks = []
+ for device in devices:
+ if not hasattr(device, method.method):
+ continue
+ await getattr(device, method.method)(**params)
+ update_tasks.append(asyncio.create_task(device.async_update_ha_state(True)))
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ for plug_service, method in SWITCH_SERVICE_TO_METHOD.items():
+ schema = method.schema or SWITCH_SERVICE_SCHEMA
+ hass.services.async_register(
+ DOMAIN, plug_service, async_service_handler, schema=schema
+ )
+
+
+def _async_setup_fan_services(hass: HomeAssistant) -> None:
+ """Set up Xiaomi Miio fan services."""
+ hass.data.setdefault(FAN_DATA_KEY, {})
+
+ async def async_service_handler(call: ServiceCall) -> None:
+ """Map services to methods on XiaomiAirPurifier."""
+ method = FAN_SERVICE_TO_METHOD[call.service]
+ params = {
+ key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID
+ }
+ if entity_ids := call.data.get(ATTR_ENTITY_ID):
+ filtered_entities = [
+ entity
+ for entity in hass.data[FAN_DATA_KEY].values()
+ if entity.entity_id in entity_ids
+ ]
+ else:
+ filtered_entities = hass.data[FAN_DATA_KEY].values()
+
+ update_tasks = []
+
+ for entity in filtered_entities:
+ entity_method = getattr(entity, method.method, None)
+ if not entity_method:
+ continue
+ await entity_method(**params)
+ update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True)))
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ for air_purifier_service, method in FAN_SERVICE_TO_METHOD.items():
+ schema = method.schema or FAN_SERVICE_SCHEMA
+ hass.services.async_register(
+ DOMAIN, air_purifier_service, async_service_handler, schema=schema
+ )
diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py
index f4fc2babc646..f379add73ba0 100644
--- a/homeassistant/components/xiaomi_miio/switch.py
+++ b/homeassistant/components/xiaomi_miio/switch.py
@@ -1,6 +1,5 @@
"""Support for Xiaomi Smart WiFi Socket and Smart Power Strip."""
-import asyncio
from dataclasses import dataclass
from functools import partial
import logging
@@ -15,7 +14,6 @@ from miio import (
)
from miio.gateway.devices.switch import Switch
from miio.powerstrip import PowerMode
-import voluptuous as vol
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -23,8 +21,6 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.const import (
- ATTR_ENTITY_ID,
- ATTR_MODE,
ATTR_MODEL,
ATTR_TEMPERATURE,
CONF_DEVICE,
@@ -33,15 +29,13 @@ from homeassistant.const import (
CONF_TOKEN,
EntityCategory,
)
-from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import config_validation as cv
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CONF_FLOW_TYPE,
CONF_GATEWAY,
- DOMAIN,
FEATURE_FLAGS_AIRFRESH,
FEATURE_FLAGS_AIRFRESH_A1,
FEATURE_FLAGS_AIRFRESH_T2017,
@@ -113,20 +107,16 @@ from .const import (
MODELS_HUMIDIFIER_MJJSQ,
MODELS_PURIFIER_MIIO,
MODELS_PURIFIER_MIOT,
- SERVICE_SET_POWER_MODE,
- SERVICE_SET_POWER_PRICE,
- SERVICE_SET_WIFI_LED_OFF,
- SERVICE_SET_WIFI_LED_ON,
SUCCESS,
+ SWITCH_DATA_KEY as DATA_KEY,
)
from .coordinator import GatewayDeviceCoordinator
from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity
-from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry
+from .typing import XiaomiMiioConfigEntry
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Xiaomi Miio Switch"
-DATA_KEY = "switch.xiaomi_miio"
MODEL_POWER_STRIP_V2 = "zimi.powerstrip.v2"
MODEL_PLUG_V3 = "chuangmi.plug.v3"
@@ -153,7 +143,6 @@ ATTR_LOAD_POWER = "load_power"
ATTR_POWER = "power"
ATTR_POWER_MODE = "power_mode"
ATTR_POWER_PRICE = "power_price"
-ATTR_PRICE = "price"
ATTR_PTC = "ptc"
ATTR_WIFI_LED = "wifi_led"
@@ -171,29 +160,6 @@ FEATURE_FLAGS_POWER_STRIP_V2 = FEATURE_SET_WIFI_LED | FEATURE_SET_POWER_PRICE
FEATURE_FLAGS_PLUG_V3 = FEATURE_SET_WIFI_LED
-SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
-
-SERVICE_SCHEMA_POWER_MODE = SERVICE_SCHEMA.extend(
- {vol.Required(ATTR_MODE): vol.All(vol.In(["green", "normal"]))}
-)
-
-SERVICE_SCHEMA_POWER_PRICE = SERVICE_SCHEMA.extend(
- {vol.Required(ATTR_PRICE): cv.positive_float}
-)
-
-SERVICE_TO_METHOD = {
- SERVICE_SET_WIFI_LED_ON: ServiceMethodDetails(method="async_set_wifi_led_on"),
- SERVICE_SET_WIFI_LED_OFF: ServiceMethodDetails(method="async_set_wifi_led_off"),
- SERVICE_SET_POWER_MODE: ServiceMethodDetails(
- method="async_set_power_mode",
- schema=SERVICE_SCHEMA_POWER_MODE,
- ),
- SERVICE_SET_POWER_PRICE: ServiceMethodDetails(
- method="async_set_power_price",
- schema=SERVICE_SCHEMA_POWER_PRICE,
- ),
-}
-
MODEL_TO_FEATURES_MAP = {
MODEL_AIRFRESH_A1: FEATURE_FLAGS_AIRFRESH_A1,
MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH,
@@ -486,42 +452,6 @@ async def async_setup_other_entry(
model,
)
- async def async_service_handler(service: ServiceCall) -> None:
- """Map services to methods on XiaomiPlugGenericSwitch."""
- method = SERVICE_TO_METHOD[service.service]
- params = {
- key: value
- for key, value in service.data.items()
- if key != ATTR_ENTITY_ID
- }
- if entity_ids := service.data.get(ATTR_ENTITY_ID):
- devices = [
- device
- for device in hass.data[DATA_KEY].values()
- if device.entity_id in entity_ids
- ]
- else:
- devices = hass.data[DATA_KEY].values()
-
- update_tasks = []
- for device in devices:
- if not hasattr(device, method.method):
- continue
- await getattr(device, method.method)(**params)
- update_tasks.append(
- asyncio.create_task(device.async_update_ha_state(True))
- )
-
- if update_tasks:
- await asyncio.wait(update_tasks)
-
- for plug_service, method in SERVICE_TO_METHOD.items():
- schema = method.schema or SERVICE_SCHEMA
- # pylint: disable-next=home-assistant-service-registered-in-setup-entry
- hass.services.async_register(
- DOMAIN, plug_service, async_service_handler, schema=schema
- )
-
async_add_entities(entities)
diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py
index 4146a59cfb31..87d428b9caef 100644
--- a/homeassistant/components/xiaomi_miio/vacuum.py
+++ b/homeassistant/components/xiaomi_miio/vacuum.py
@@ -92,7 +92,6 @@ class MiroboVacuum(
| VacuumEntityFeature.FAN_SPEED
| VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.LOCATE
- | VacuumEntityFeature.BATTERY
| VacuumEntityFeature.CLEAN_SPOT
| VacuumEntityFeature.START
)
@@ -114,12 +113,6 @@ class MiroboVacuum(
return super().activity
- @property
- @override
- def battery_level(self) -> int:
- """Return the battery level of the vacuum cleaner."""
- return self.coordinator.data.status.battery
-
@property
@override
def fan_speed(self) -> str:
diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py
index fbdbd69285de..8ef2f179b5cf 100644
--- a/homeassistant/components/yale/lock.py
+++ b/homeassistant/components/yale/lock.py
@@ -8,7 +8,11 @@ from yalexs.activity import ActivityType
from yalexs.lock import Lock, LockOperation, LockStatus
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
-from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
+from homeassistant.components.lock import (
+ LockEntity,
+ LockEntityFeature,
+ LockEntityStateAttribute,
+)
from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -140,5 +144,7 @@ class YaleLock(YaleEntity, RestoreEntity, LockEntity):
if not (last_state := await self.async_get_last_state()):
return
- if ATTR_CHANGED_BY in last_state.attributes:
- self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY]
+ if LockEntityStateAttribute.CHANGED_BY in last_state.attributes:
+ self._attr_changed_by = last_state.attributes[
+ LockEntityStateAttribute.CHANGED_BY
+ ]
diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py
index 96daa42561bb..b6b7ffcbef0d 100644
--- a/homeassistant/components/yardian/__init__.py
+++ b/homeassistant/components/yardian/__init__.py
@@ -10,6 +10,7 @@ from .coordinator import YardianConfigEntry, YardianUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
+ Platform.BUTTON,
Platform.SENSOR,
Platform.SWITCH,
]
diff --git a/homeassistant/components/yardian/button.py b/homeassistant/components/yardian/button.py
new file mode 100644
index 000000000000..379fa2c17df2
--- /dev/null
+++ b/homeassistant/components/yardian/button.py
@@ -0,0 +1,44 @@
+"""Support for Yardian buttons."""
+
+import asyncio
+from typing import override
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import BUTTON_REFRESH_DELAY
+from .coordinator import YardianConfigEntry, YardianUpdateCoordinator
+from .entity import YardianEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: YardianConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Yardian button platform."""
+ coordinator = entry.runtime_data
+
+ async_add_entities([YardianStopButton(coordinator)])
+
+
+class YardianStopButton(YardianEntity, ButtonEntity):
+ """Representation of a Yardian Stop All Irrigation button."""
+
+ _attr_translation_key = "stop_irrigation"
+
+ def __init__(self, coordinator: YardianUpdateCoordinator) -> None:
+ """Initialize the button."""
+ super().__init__(coordinator)
+ self.client = coordinator.controller
+
+ self._attr_unique_id = f"{coordinator.yid}_stop_all"
+
+ @override
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ await self.client.stop_irrigation()
+
+ await asyncio.sleep(BUTTON_REFRESH_DELAY)
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/yardian/const.py b/homeassistant/components/yardian/const.py
index 4b77d8d8b79b..fcbedaed7ee9 100644
--- a/homeassistant/components/yardian/const.py
+++ b/homeassistant/components/yardian/const.py
@@ -6,3 +6,4 @@ PRODUCT_NAME = "Yardian Smart Sprinkler Controller"
DEFAULT_WATERING_DURATION = 6
SWITCH_REFRESH_DELAY = 2
+BUTTON_REFRESH_DELAY = 3
diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json
index 50db7dd91cf6..b9687238016b 100644
--- a/homeassistant/components/yardian/strings.json
+++ b/homeassistant/components/yardian/strings.json
@@ -35,6 +35,11 @@
"name": "Enabled"
}
},
+ "button": {
+ "stop_irrigation": {
+ "name": "Stop irrigation"
+ }
+ },
"sensor": {
"active_zone_count": {
"name": "Active zones",
diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py
index 853d455844c8..760fc99246c6 100644
--- a/homeassistant/components/yardian/switch.py
+++ b/homeassistant/components/yardian/switch.py
@@ -79,6 +79,6 @@ class YardianSwitch(YardianZoneEntity, SwitchEntity):
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
- await self.coordinator.controller.stop_irrigation()
+ await self.coordinator.controller.stop_zone(self._zone_id)
await asyncio.sleep(SWITCH_REFRESH_DELAY)
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json
index 26c776975cd8..26bc44411bc3 100644
--- a/homeassistant/components/yeelight/manifest.json
+++ b/homeassistant/components/yeelight/manifest.json
@@ -17,7 +17,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["async_upnp_client", "yeelight"],
- "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.2"],
+ "requirements": ["yeelight==0.7.16", "async-upnp-client==0.47.0"],
"zeroconf": [
{
"name": "yeelink-*",
diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py
index a1917c847870..c2404ea1419c 100644
--- a/homeassistant/components/yolink/__init__.py
+++ b/homeassistant/components/yolink/__init__.py
@@ -169,9 +169,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: YoLinkConfigEntry) -> bo
identifier[0] == DOMAIN
and device_coordinators.get(identifier[1]) is None
):
- device_registry.async_update_device(
- device_entry.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(device_entry.id)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py
index 4c0d4e6464b5..2842714454dd 100644
--- a/homeassistant/components/yolink/valve.py
+++ b/homeassistant/components/yolink/valve.py
@@ -62,6 +62,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = (
device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER
and not device.device_model_name.startswith(DEV_MODEL_WATER_METER_YS5007)
),
+ should_update_entity=lambda value: value is not None,
),
YoLinkValveEntityDescription(
key="valve_1_state",
@@ -71,6 +72,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = (
exists_fn=lambda device: (
device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER
),
+ should_update_entity=lambda value: value is not None,
channel_index=0,
),
YoLinkValveEntityDescription(
@@ -81,6 +83,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = (
exists_fn=lambda device: (
device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER
),
+ should_update_entity=lambda value: value is not None,
channel_index=1,
),
YoLinkValveEntityDescription(
diff --git a/homeassistant/components/yoto/coordinator.py b/homeassistant/components/yoto/coordinator.py
index 026b00eb11ed..b3d94b1bb4af 100644
--- a/homeassistant/components/yoto/coordinator.py
+++ b/homeassistant/components/yoto/coordinator.py
@@ -161,9 +161,7 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
(ident[1] for ident in device.identifiers if ident[0] == DOMAIN), None
)
if player_id is not None and player_id not in self.client.players:
- device_registry.async_update_device(
- device.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_registry.async_remove_device(device.id)
async def _async_load_library(self) -> None:
"""Load the card library and groups; failures only affect browsing."""
diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py
index dff9652398d7..b6f12c7efc9c 100644
--- a/homeassistant/components/youtube/__init__.py
+++ b/homeassistant/components/youtube/__init__.py
@@ -71,6 +71,4 @@ async def delete_devices(
dev_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
for dev_entry in dev_entries:
if any(identifier[1] in channel_ids for identifier in dev_entry.identifiers):
- device_registry.async_update_device(
- dev_entry.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(dev_entry.id)
diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py
index 388d22664ea2..952496bdce62 100644
--- a/homeassistant/components/zha/__init__.py
+++ b/homeassistant/components/zha/__init__.py
@@ -9,6 +9,7 @@ from yarl import URL
from zha.application.const import BAUD_RATES, RadioType
from zha.application.gateway import Gateway
from zha.application.helpers import ZHAData
+from zha.quirks import DEVICE_REGISTRY
from zha.zigbee.device import get_device_automation_triggers
from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH
from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError
@@ -158,11 +159,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
zha_gateway = await Gateway.async_from_config(zha_lib_data)
- # Load and cache device trigger information early
+ # Load and cache device trigger information early. Quirks were registered by
+ # `Gateway.async_from_config` above, so pass the resolver to quirk devices
+ # and surface quirk-defined triggers (e.g. remote button presses).
device_registry = dr.async_get(hass)
radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry)
- async with radio_mgr.create_zigpy_app(connect=False) as app:
+ async with radio_mgr.create_zigpy_app(
+ connect=False, device_resolver=DEVICE_REGISTRY.resolve
+ ) as app:
for dev in app.devices.values():
dev_entry = device_registry.async_get_device(
identifiers={(DOMAIN, str(dev.ieee))},
diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py
index 69ec6a03fa2d..43d4f7c421f3 100644
--- a/homeassistant/components/zha/light.py
+++ b/homeassistant/components/zha/light.py
@@ -12,7 +12,6 @@ from zha.application.platforms.light.const import (
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
- ATTR_COLOR_MODE,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
@@ -21,6 +20,7 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
LightEntityFeature,
+ LightEntityStateAttribute,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, Platform
@@ -215,20 +215,27 @@ class Light(LightEntity, ZHAEntity):
"""Restore entity state."""
color_temp = (
color_util.color_temperature_kelvin_to_mired(color_temp_k)
- if (color_temp_k := state.attributes.get(ATTR_COLOR_TEMP_KELVIN))
+ if (
+ color_temp_k := state.attributes.get(
+ LightEntityStateAttribute.COLOR_TEMP_KELVIN
+ )
+ )
else None
)
self.entity_data.entity.restore_external_state_attributes(
state=(state.state == STATE_ON),
off_with_transition=state.attributes.get(OFF_WITH_TRANSITION),
off_brightness=state.attributes.get(OFF_BRIGHTNESS),
- brightness=state.attributes.get(ATTR_BRIGHTNESS),
+ brightness=state.attributes.get(LightEntityStateAttribute.BRIGHTNESS),
color_temp=color_temp,
- xy_color=state.attributes.get(ATTR_XY_COLOR),
+ xy_color=state.attributes.get(LightEntityStateAttribute.XY_COLOR),
color_mode=(
- HA_TO_ZHA_COLOR_MODE[ColorMode(state.attributes[ATTR_COLOR_MODE])]
- if state.attributes.get(ATTR_COLOR_MODE) is not None
+ HA_TO_ZHA_COLOR_MODE[
+ ColorMode(state.attributes[LightEntityStateAttribute.COLOR_MODE])
+ ]
+ if state.attributes.get(LightEntityStateAttribute.COLOR_MODE)
+ is not None
else None
),
- effect=state.attributes.get(ATTR_EFFECT),
+ effect=state.attributes.get(LightEntityStateAttribute.EFFECT),
)
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index c6db84d60e77..7112914208f5 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "zha",
"name": "Zigbee Home Automation",
- "after_dependencies": ["hassio", "onboarding", "usb"],
+ "after_dependencies": ["hassio", "onboarding"],
"codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"],
"config_flow": true,
- "dependencies": ["file_upload", "homeassistant_hardware"],
+ "dependencies": ["file_upload", "homeassistant_hardware", "usb"],
"documentation": "https://www.home-assistant.io/integrations/zha",
"integration_type": "hub",
"iot_class": "local_polling",
@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
- "requirements": ["zha==2.0.0", "zha-quirks==2.1.0"],
+ "requirements": ["zha==2.0.1", "zha-quirks==2.1.1"],
"usb": [
{
"description": "*2652*",
diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py
index cdae1558e8e1..6ef2e4013880 100644
--- a/homeassistant/components/zha/radio_manager.py
+++ b/homeassistant/components/zha/radio_manager.py
@@ -1,7 +1,7 @@
"""ZHA radio manager."""
import asyncio
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Callable
import contextlib
from contextlib import suppress
import copy
@@ -22,6 +22,7 @@ from zigpy.config import (
CONF_NWK_BACKUP_ENABLED,
SCHEMA_DEVICE,
)
+import zigpy.device
from zigpy.exceptions import NetworkNotFormed
from homeassistant import config_entries
@@ -174,9 +175,17 @@ class ZhaRadioManager:
@contextlib.asynccontextmanager
async def create_zigpy_app(
- self, *, connect: bool = True
+ self,
+ *,
+ connect: bool = True,
+ device_resolver: Callable[[zigpy.device.Device], zigpy.device.Device]
+ | None = None,
) -> AsyncGenerator[ControllerApplication]:
- """Connect to the radio with the current config and then clean up."""
+ """Connect to the radio with the current config and then clean up.
+
+ `device_resolver` is forwarded to zigpy so devices loaded from the
+ database are quirk-resolved to get quirk-defined device triggers.
+ """
assert self.radio_type is not None
config = get_zha_data(self.hass).yaml_config
@@ -201,7 +210,10 @@ class ZhaRadioManager:
app_config[CONF_USE_THREAD] = False
app = await self.radio_type.controller.new(
- app_config, auto_form=False, start_radio=False
+ app_config,
+ auto_form=False,
+ start_radio=False,
+ device_resolver=device_resolver,
)
try:
diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py
index 18f7e6c34ad5..f53a4226fa6a 100644
--- a/homeassistant/components/zone/__init__.py
+++ b/homeassistant/components/zone/__init__.py
@@ -9,7 +9,7 @@ from typing import Any, Self, cast, override
import voluptuous as vol
from homeassistant import config_entries
-from homeassistant.const import (
+from homeassistant.const import ( # noqa: F401
ATTR_EDITABLE,
ATTR_LATITUDE,
ATTR_LONGITUDE,
@@ -20,9 +20,11 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_NAME,
CONF_RADIUS,
+ DEFAULT_RADIUS,
EVENT_CORE_CONFIG_UPDATE,
SERVICE_RELOAD,
STATE_UNAVAILABLE,
+ EntityStateAttribute,
)
from homeassistant.core import (
Event,
@@ -44,12 +46,18 @@ from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.location import distance
-from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE
+from .const import ( # noqa: F401
+ ATTR_PASSIVE,
+ ATTR_RADIUS,
+ CONF_PASSIVE,
+ DOMAIN,
+ HOME_ZONE,
+ ZoneEntityStateAttribute,
+)
_LOGGER = logging.getLogger(__name__)
DEFAULT_PASSIVE = False
-DEFAULT_RADIUS = 100
ENTITY_ID_FORMAT = "zone.{}"
ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE)
@@ -142,21 +150,24 @@ def async_in_zones(
zone_dist := distance(
latitude,
longitude,
- zone_attrs[ATTR_LATITUDE],
- zone_attrs[ATTR_LONGITUDE],
+ zone_attrs[EntityStateAttribute.LATITUDE],
+ zone_attrs[EntityStateAttribute.LONGITUDE],
)
)
is None
# Skip zone that are outside the radius aka the
# lat/long is outside the zone
- or not (zone_dist - (zone_radius := zone_attrs[ATTR_RADIUS]) < radius)
+ or not (
+ zone_dist - (zone_radius := zone_attrs[ZoneEntityStateAttribute.RADIUS])
+ < radius
+ )
):
continue
zones.append((zone.entity_id, zone_dist, zone_radius))
# Skip passive zones
- if zone_attrs.get(ATTR_PASSIVE):
+ if zone_attrs.get(ZoneEntityStateAttribute.PASSIVE):
continue
# Prefer the smallest zone, using distance to its center as a tie
@@ -198,9 +209,9 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[
):
return []
input_attrs = input_zone.attributes
- input_latitude: float = input_attrs[ATTR_LATITUDE]
- input_longitude: float = input_attrs[ATTR_LONGITUDE]
- input_radius: float = input_attrs[ATTR_RADIUS]
+ input_latitude: float = input_attrs[EntityStateAttribute.LATITUDE]
+ input_longitude: float = input_attrs[EntityStateAttribute.LONGITUDE]
+ input_radius: float = input_attrs[ZoneEntityStateAttribute.RADIUS]
zones: list[tuple[str, float, float]] = []
@@ -221,12 +232,12 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[
zone_dist := distance(
input_latitude,
input_longitude,
- zone_attrs[ATTR_LATITUDE],
- zone_attrs[ATTR_LONGITUDE],
+ zone_attrs[EntityStateAttribute.LATITUDE],
+ zone_attrs[EntityStateAttribute.LONGITUDE],
)
) is None:
continue
- zone_radius = zone_attrs[ATTR_RADIUS]
+ zone_radius = zone_attrs[ZoneEntityStateAttribute.RADIUS]
if not zone_dist + input_radius <= zone_radius:
continue
zones.append((zone.entity_id, zone_dist, zone_radius))
@@ -281,13 +292,15 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) -
zone_dist = distance(
latitude,
longitude,
- zone.attributes[ATTR_LATITUDE],
- zone.attributes[ATTR_LONGITUDE],
+ zone.attributes[EntityStateAttribute.LATITUDE],
+ zone.attributes[EntityStateAttribute.LONGITUDE],
)
- if zone_dist is None or zone.attributes[ATTR_RADIUS] is None:
+ if zone_dist is None or zone.attributes[ZoneEntityStateAttribute.RADIUS] is None:
return False
- return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS])
+ return zone_dist - radius < cast(
+ float, zone.attributes[ZoneEntityStateAttribute.RADIUS]
+ )
class ZoneStorageCollection(collection.DictStorageCollection):
@@ -508,12 +521,12 @@ class Zone(collection.CollectionEntity):
def _generate_attrs(self) -> None:
"""Generate new attrs based on config."""
self._attr_extra_state_attributes = {
- ATTR_LATITUDE: self._config[CONF_LATITUDE],
- ATTR_LONGITUDE: self._config[CONF_LONGITUDE],
- ATTR_RADIUS: self._config[CONF_RADIUS],
- ATTR_PASSIVE: self._config[CONF_PASSIVE],
- ATTR_PERSONS: sorted(self._persons_in_zone),
- ATTR_EDITABLE: self.editable,
+ EntityStateAttribute.LATITUDE: self._config[CONF_LATITUDE],
+ EntityStateAttribute.LONGITUDE: self._config[CONF_LONGITUDE],
+ ZoneEntityStateAttribute.RADIUS: self._config[CONF_RADIUS],
+ ZoneEntityStateAttribute.PASSIVE: self._config[CONF_PASSIVE],
+ ZoneEntityStateAttribute.PERSONS: sorted(self._persons_in_zone),
+ ZoneEntityStateAttribute.EDITABLE: self.editable,
}
@callback
diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py
index f831195c4193..ae00b0bff599 100644
--- a/homeassistant/components/zone/condition.py
+++ b/homeassistant/components/zone/condition.py
@@ -5,14 +5,15 @@ from typing import Any, Unpack, cast, override
import voluptuous as vol
from homeassistant.components.device_tracker import (
- ATTR_IN_ZONES,
DOMAIN as DEVICE_TRACKER_DOMAIN,
+ DeviceTrackerEntityStateAttribute,
+)
+from homeassistant.components.person import (
+ DOMAIN as PERSON_DOMAIN,
+ PersonEntityStateAttribute,
)
-from homeassistant.components.person import DOMAIN as PERSON_DOMAIN
from homeassistant.const import (
ATTR_GPS_ACCURACY,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
CONF_ENTITY_ID,
CONF_FOR,
CONF_OPTIONS,
@@ -20,6 +21,7 @@ from homeassistant.const import (
CONF_ZONE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage
@@ -41,6 +43,7 @@ from homeassistant.helpers.typing import ConfigType
from . import in_zone
from .const import DOMAIN
+from .helpers import get_in_zones_attribute
_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
@@ -48,8 +51,6 @@ _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
}
_CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT})
-_IN_ZONES_DOMAINS = {DEVICE_TRACKER_DOMAIN, PERSON_DOMAIN}
-
def zone(
hass: HomeAssistant,
@@ -88,14 +89,13 @@ def zone(
# Prefer the in_zones attribute reported by the entity (e.g. person,
# device_tracker) over recomputing membership from coordinates.
- if (
- entity.domain in _IN_ZONES_DOMAINS
- and (in_zones := entity.attributes.get(ATTR_IN_ZONES)) is not None
- ):
+ if (in_zones_attr := get_in_zones_attribute(entity)) is not None and (
+ in_zones := entity.attributes.get(in_zones_attr)
+ ) is not None:
return zone_ent.entity_id in in_zones
- latitude = entity.attributes.get(ATTR_LATITUDE)
- longitude = entity.attributes.get(ATTR_LONGITUDE)
+ latitude = entity.attributes.get(EntityStateAttribute.LATITUDE)
+ longitude = entity.attributes.get(EntityStateAttribute.LONGITUDE)
if latitude is None:
raise ConditionErrorMessage(
@@ -178,8 +178,10 @@ class ZoneCondition(Condition):
_DOMAIN_SPECS: dict[str, DomainSpec] = {
- "person": DomainSpec(value_source=ATTR_IN_ZONES),
- "device_tracker": DomainSpec(value_source=ATTR_IN_ZONES),
+ PERSON_DOMAIN: DomainSpec(value_source=PersonEntityStateAttribute.IN_ZONES),
+ DEVICE_TRACKER_DOMAIN: DomainSpec(
+ value_source=DeviceTrackerEntityStateAttribute.IN_ZONES
+ ),
}
_ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
@@ -205,8 +207,11 @@ class _ZoneTargetConditionBase(EntityConditionBase):
def _in_target_zone(self, entity_state: State) -> bool:
"""Check if the entity is currently in the selected zone."""
- in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or ()
- return self._zone in in_zones
+ if (in_zones_attr := get_in_zones_attribute(entity_state)) and (
+ in_zones := entity_state.attributes.get(in_zones_attr)
+ ):
+ return self._zone in in_zones
+ return False
class InZoneCondition(_ZoneTargetConditionBase):
diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py
index af6ac00cbbb5..0725b2ce6fdf 100644
--- a/homeassistant/components/zone/const.py
+++ b/homeassistant/components/zone/const.py
@@ -1,7 +1,20 @@
"""Constants for the zone component."""
+from enum import StrEnum
+
CONF_PASSIVE = "passive"
DOMAIN = "zone"
HOME_ZONE = "home"
+
+
+class ZoneEntityStateAttribute(StrEnum):
+ """State attributes for zone entities."""
+
+ RADIUS = "radius"
+ PASSIVE = "passive"
+ PERSONS = "persons"
+ EDITABLE = "editable"
+
+
ATTR_PASSIVE = "passive"
ATTR_RADIUS = "radius"
diff --git a/homeassistant/components/zone/helpers.py b/homeassistant/components/zone/helpers.py
new file mode 100644
index 000000000000..5e83c6e9c7b3
--- /dev/null
+++ b/homeassistant/components/zone/helpers.py
@@ -0,0 +1,24 @@
+"""Helpers for the zone integration."""
+
+from homeassistant.components.device_tracker import (
+ DOMAIN as DEVICE_TRACKER_DOMAIN,
+ DeviceTrackerEntityStateAttribute,
+)
+from homeassistant.components.person import (
+ DOMAIN as PERSON_DOMAIN,
+ PersonEntityStateAttribute,
+)
+from homeassistant.core import State
+
+
+def get_in_zones_attribute(state: State) -> str | None:
+ """Return the in_zones attribute for the tracked entity, or None.
+
+ Only person and device_tracker entities report zone membership; each
+ exposes it under its own platform enum. Any other domain returns None.
+ """
+ if state.domain == PERSON_DOMAIN:
+ return PersonEntityStateAttribute.IN_ZONES
+ if state.domain == DEVICE_TRACKER_DOMAIN:
+ return DeviceTrackerEntityStateAttribute.IN_ZONES
+ return None
diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py
index f9fa1f9aea8b..c23a3bb6f3c9 100644
--- a/homeassistant/components/zone/trigger.py
+++ b/homeassistant/components/zone/trigger.py
@@ -5,15 +5,14 @@ from typing import TYPE_CHECKING, Any, cast, override
import voluptuous as vol
-from homeassistant.components.device_tracker import ATTR_IN_ZONES
from homeassistant.const import (
- ATTR_FRIENDLY_NAME,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_FOR,
CONF_OPTIONS,
CONF_TARGET,
CONF_ZONE,
+ EntityStateAttribute,
)
from homeassistant.core import (
CALLBACK_TYPE,
@@ -45,8 +44,8 @@ from homeassistant.helpers.trigger import (
from homeassistant.helpers.typing import ConfigType
from . import condition
-from .condition import _IN_ZONES_DOMAINS
from .const import DOMAIN
+from .helpers import get_in_zones_attribute
EVENT_ENTER = "enter"
EVENT_LEAVE = "leave"
@@ -65,7 +64,8 @@ def _state_has_zone_info(state: State) -> bool:
tracker); other entities are matched by their coordinates.
"""
return location.has_location(state) or (
- state.domain in _IN_ZONES_DOMAINS and ATTR_IN_ZONES in state.attributes
+ (in_zones_attr := get_in_zones_attribute(state)) is not None
+ and in_zones_attr in state.attributes
)
@@ -168,7 +168,7 @@ class LegacyZoneTrigger(Trigger):
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
- description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
+ description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[EntityStateAttribute.FRIENDLY_NAME]}"
run_action(
{
"entity_id": entity,
@@ -199,8 +199,11 @@ class ZoneTriggerBase(EntityTriggerBase):
def _in_target_zone(self, state: State) -> bool:
"""Check if the entity is in the selected zone."""
- in_zones = state.attributes.get(ATTR_IN_ZONES) or ()
- return self._zone in in_zones
+ if (in_zones_attr := get_in_zones_attribute(state)) and (
+ in_zones := state.attributes.get(in_zones_attr)
+ ):
+ return self._zone in in_zones
+ return False
class EnteredZoneTrigger(ZoneTriggerBase):
diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py
index b9e533742690..131d8e1fcb84 100644
--- a/homeassistant/components/zwave_js/climate.py
+++ b/homeassistant/components/zwave_js/climate.py
@@ -557,7 +557,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
# and cool to mirror previous behavior. If none of those are available, set it
# to the first available mode that is not off.
try:
- hvac_mode = next(
+ hvac_mode: HVACMode = next(
mode
for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL)
if mode in self._hvac_modes
diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py
index 32d60b6269ec..db6fde0f7871 100644
--- a/homeassistant/components/zwave_js/config_flow.py
+++ b/homeassistant/components/zwave_js/config_flow.py
@@ -456,13 +456,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
self.ws_address = f"ws://{discovery_info.host}:{discovery_info.port}"
home_id_display = format_home_id_for_display(int(home_id))
- # Show home ID and network location in discovery notification
self.context.update(
{
"title_placeholders": {
- "host": discovery_info.host,
- "port": str(discovery_info.port),
- "home_id": home_id_display,
+ CONF_NAME: (
+ f"Network {home_id_display} at "
+ f"{discovery_info.host}:{discovery_info.port}"
+ )
}
}
)
@@ -1574,8 +1574,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
)
self.socket_path = discovery_info.socket_path
+ home_id_display = format_home_id_for_display(discovery_info.zwave_home_id)
self.context["title_placeholders"] = {
- CONF_NAME: f"{discovery_info.name} via ESPHome"
+ CONF_NAME: f"Network {home_id_display} via {discovery_info.name} (ESPHome)"
}
self._adapter_discovered = True
diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py
index 1e07651bc2bc..a40baddc99db 100644
--- a/homeassistant/components/zwave_js/lock.py
+++ b/homeassistant/components/zwave_js/lock.py
@@ -244,9 +244,10 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity):
LOGGER.info("%s after setting lock configuration for %s", msg, self.entity_id)
async def async_set_user(self, **kwargs: Any) -> SetUserReturn:
- """Create or update an access-control user on the lock."""
+ """Create or update an access-control user, optionally with a credential."""
user_type = kwargs.get(const.ATTR_USER_TYPE)
credential_rule = kwargs.get(const.ATTR_CREDENTIAL_RULE)
+ credential_type = kwargs.get(const.ATTR_CREDENTIAL_TYPE)
try:
return await lock_helpers.async_set_user(
self.info.node,
@@ -261,6 +262,13 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity):
else None
),
active=kwargs.get(const.ATTR_USER_ACTIVE),
+ credential_type=(
+ CREDENTIAL_TYPE_REVERSE_MAP[credential_type]
+ if credential_type is not None
+ else None
+ ),
+ credential_slot=kwargs.get(const.ATTR_CREDENTIAL_SLOT),
+ credential_data=kwargs.get(const.ATTR_CREDENTIAL_DATA),
)
except BaseZwaveJSServerError as err:
raise _credential_service_error("set_user_failed", err) from err
diff --git a/homeassistant/components/zwave_js/lock_helpers.py b/homeassistant/components/zwave_js/lock_helpers.py
index e4d3c89adeb7..c66500be18f8 100644
--- a/homeassistant/components/zwave_js/lock_helpers.py
+++ b/homeassistant/components/zwave_js/lock_helpers.py
@@ -16,7 +16,12 @@ from zwave_js_server.const.command_class.access_control import (
UserCredentialType,
UserCredentialUserType,
)
-from zwave_js_server.model.access_control import SetUserOptions
+from zwave_js_server.exceptions import FailedZWaveCommand
+from zwave_js_server.model.access_control import (
+ AddUserCredential,
+ SetUserOptions,
+ UserCredentialCapability,
+)
from zwave_js_server.model.node import Node
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@@ -191,6 +196,8 @@ class SetUserReturn(TypedDict):
"""Return type for set_user."""
user_id: int
+ # None unless a credential was written in the same call.
+ credential_slot: int | None
class SetCredentialReturn(TypedDict):
@@ -200,6 +207,121 @@ class SetCredentialReturn(TypedDict):
user_id: int
+async def _validate_credential_data(
+ node: Node,
+ credential_type: UserCredentialType,
+ credential_data: str,
+) -> UserCredentialCapability:
+ """Validate a credential payload against the device capabilities.
+
+ Returns the capability for the credential type so callers can reuse its
+ slot information.
+ """
+ cred_type_str = CREDENTIAL_TYPE_MAP.get(credential_type, str(credential_type))
+ cred_caps = await node.access_control.get_credential_capabilities_cached()
+ type_cap = cred_caps.supported_credential_types.get(credential_type)
+ if type_cap is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="credential_type_not_supported",
+ translation_placeholders={"credential_type": cred_type_str},
+ )
+
+ if not (
+ type_cap.min_credential_length
+ <= len(credential_data)
+ <= type_cap.max_credential_length
+ ):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="credential_data_invalid_length",
+ translation_placeholders={
+ "credential_type": cred_type_str,
+ "min_length": str(type_cap.min_credential_length),
+ "max_length": str(type_cap.max_credential_length),
+ },
+ )
+ if credential_type is UserCredentialType.PIN_CODE and not (
+ credential_data.isascii() and credential_data.isdigit()
+ ):
+ # str.isdigit() accepts non-ASCII digit code points (e.g. Arabic-Indic),
+ # which the lock firmware cannot store. Restrict to ASCII 0-9.
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="credential_data_pin_not_digits",
+ )
+
+ return type_cap
+
+
+async def _async_find_available_user_slot(node: Node) -> int:
+ """Return the first unused user slot, raising if the lock is full."""
+ user_caps = await node.access_control.get_user_capabilities_cached()
+ users = await node.access_control.get_users_cached()
+ used_ids = {u.user_id for u in users}
+ user_id = next(
+ (i for i in range(1, user_caps.max_users + 1) if i not in used_ids),
+ None,
+ )
+ if user_id is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_available_user_slots",
+ )
+ return user_id
+
+
+async def _async_find_available_credential_slot(
+ node: Node,
+ credential_type: UserCredentialType,
+ type_cap: UserCredentialCapability,
+) -> int:
+ """Return the first unused slot for a credential type, raising if full."""
+ existing = await node.access_control.get_credentials_by_type_cached(credential_type)
+ used_slots = {c.slot for c in existing}
+ slot = next(
+ (
+ s
+ for s in range(1, type_cap.number_of_credential_slots + 1)
+ if s not in used_slots
+ ),
+ None,
+ )
+ if slot is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_available_credential_slots",
+ translation_placeholders={
+ "credential_type": CREDENTIAL_TYPE_MAP.get(
+ credential_type, str(credential_type)
+ )
+ },
+ )
+ return slot
+
+
+async def _async_resolve_credential_slot(
+ node: Node,
+ user_id: int,
+ credential_type: UserCredentialType,
+ type_cap: UserCredentialCapability,
+ credential_slot: int | None,
+) -> int:
+ """Resolve the slot a credential should be written to for a user."""
+ # An explicit slot is honored as-is.
+ if credential_slot is not None:
+ return credential_slot
+ # When the lock supports independent user and credentials, find the first
+ # available slot for the credential.
+ user_caps = await node.access_control.get_user_capabilities_cached()
+ if user_caps.supports_users_without_credentials:
+ return await _async_find_available_credential_slot(
+ node, credential_type, type_cap
+ )
+ # Otherwise the credential must live in the user's own slot.
+ return user_id
+
+
# --- Business logic functions ---
@@ -298,30 +420,17 @@ async def async_set_user(
user_type: UserCredentialUserType | None = None,
credential_rule: UserCredentialRule | None = None,
active: bool | None = None,
+ credential_slot: int | None = None,
+ credential_type: UserCredentialType | None = None,
+ credential_data: str | None = None,
) -> SetUserReturn:
- """Create or update an access-control user. Returns the allocated user_id."""
- supported = await node.access_control.is_supported()
- if not supported:
+ """Create or update an access-control user, optionally with a credential."""
+ if not await node.access_control.is_supported():
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="access_control_not_supported",
)
- # Auto-find first available user slot
- if user_id is None:
- user_caps = await node.access_control.get_user_capabilities_cached()
- users = await node.access_control.get_users_cached()
- used_ids = {u.user_id for u in users}
- user_id = next(
- (i for i in range(1, user_caps.max_users + 1) if i not in used_ids),
- None,
- )
- if user_id is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="no_available_user_slots",
- )
-
options = SetUserOptions(
active=active,
user_type=user_type,
@@ -329,9 +438,89 @@ async def async_set_user(
credential_rule=credential_rule,
)
+ # No user_id => create a new user via addUser, which handles locks that require
+ # credentials to be created alongside their user.
+ if user_id is None:
+ return await _async_create_user(
+ node, options, credential_slot, credential_type, credential_data
+ )
+
+ # A user_id => update the existing user via setUser, then write any
+ # credential separately (addUser only creates new users).
status = await node.access_control.set_user(user_id, options)
_raise_on_set_user_error(status)
- return SetUserReturn(user_id=user_id)
+
+ resolved_slot: int | None = None
+ if credential_type is not None and credential_data is not None:
+ type_cap = await _validate_credential_data(
+ node, credential_type, credential_data
+ )
+ resolved_slot = await _async_resolve_credential_slot(
+ node, user_id, credential_type, type_cap, credential_slot
+ )
+ cred_status = await node.access_control.set_credential(
+ user_id, credential_type, resolved_slot, credential_data
+ )
+ _raise_on_set_credential_error(cred_status)
+
+ return SetUserReturn(user_id=user_id, credential_slot=resolved_slot)
+
+
+async def _async_create_user(
+ node: Node,
+ options: SetUserOptions,
+ credential_slot: int | None,
+ credential_type: UserCredentialType | None,
+ credential_data: str | None,
+) -> SetUserReturn:
+ """Create a new user via addUser, optionally bundling a credential."""
+ user_id = await _async_find_available_user_slot(node)
+
+ credential: AddUserCredential | None = None
+ resolved_slot: int | None = None
+ if credential_type is not None and credential_data is not None:
+ type_cap = await _validate_credential_data(
+ node, credential_type, credential_data
+ )
+ resolved_slot = await _async_resolve_credential_slot(
+ node, user_id, credential_type, type_cap, credential_slot
+ )
+ credential = AddUserCredential(
+ credential_type=credential_type,
+ credential_slot=resolved_slot,
+ data=credential_data,
+ )
+ else:
+ user_caps = await node.access_control.get_user_capabilities_cached()
+ if not user_caps.supports_users_without_credentials:
+ # On User Code CC a user cannot exist without its code, so zwave-js
+ # rejects addUser without a credential. Fail early with a clear
+ # error instead of letting the command fail downstream.
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="credential_required",
+ )
+
+ result = await node.access_control.add_user(user_id, options, credential)
+ _raise_on_set_user_error(result.user)
+ if (
+ result.credential is not None
+ and result.credential is not SetCredentialResult.OK
+ ):
+ # addUser creates the user before writing the credential, so on User
+ # Credential CC a failed credential write leaves a credential-less user
+ # behind. Roll it back before surfacing the error. (On User Code CC the
+ # user and credential share a slot, so this cannot occur.)
+ try:
+ await node.access_control.delete_user(user_id)
+ except FailedZWaveCommand:
+ _LOGGER.warning(
+ "Could not roll back user %s after its credential failed to add",
+ user_id,
+ )
+ _raise_on_set_credential_error(result.credential)
+
+ return SetUserReturn(user_id=user_id, credential_slot=resolved_slot)
async def async_delete_user(node: Node, user_id: int) -> None:
@@ -378,60 +567,12 @@ async def async_set_credential(
translation_key="access_control_not_supported",
)
- cred_type_str = CREDENTIAL_TYPE_MAP.get(credential_type, str(credential_type))
- cred_caps = await node.access_control.get_credential_capabilities_cached()
- type_cap = cred_caps.supported_credential_types.get(credential_type)
- if type_cap is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="credential_type_not_supported",
- translation_placeholders={"credential_type": cred_type_str},
- )
-
- # Validate credential_data length and format against device capabilities
- if not (
- type_cap.min_credential_length
- <= len(credential_data)
- <= type_cap.max_credential_length
- ):
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="credential_data_invalid_length",
- translation_placeholders={
- "credential_type": cred_type_str,
- "min_length": str(type_cap.min_credential_length),
- "max_length": str(type_cap.max_credential_length),
- },
- )
- if credential_type is UserCredentialType.PIN_CODE and not (
- credential_data.isascii() and credential_data.isdigit()
- ):
- # str.isdigit() accepts non-ASCII digit code points (e.g. Arabic-Indic),
- # which the lock firmware cannot store. Restrict to ASCII 0-9.
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="credential_data_pin_not_digits",
- )
+ type_cap = await _validate_credential_data(node, credential_type, credential_data)
if credential_slot is None:
- existing = await node.access_control.get_credentials_by_type_cached(
- credential_type
+ credential_slot = await _async_find_available_credential_slot(
+ node, credential_type, type_cap
)
- used_slots = {c.slot for c in existing}
- credential_slot = next(
- (
- s
- for s in range(1, type_cap.number_of_credential_slots + 1)
- if s not in used_slots
- ),
- None,
- )
- if credential_slot is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="no_available_credential_slots",
- translation_placeholders={"credential_type": cred_type_str},
- )
status = await node.access_control.set_credential(
user_id, credential_type, credential_slot, credential_data
@@ -450,7 +591,12 @@ async def async_delete_credential(
credential_type: UserCredentialType,
credential_slot: int,
) -> None:
- """Delete a single credential."""
+ """Delete a single credential.
+
+ On User Code CC the credential shares its user's slot, so zwave-js clears
+ the whole slot and deletes the user along with the credential; no special
+ handling is needed here.
+ """
if not await node.access_control.is_supported():
raise HomeAssistantError(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py
index 92f45f90ab71..80aba468d7ad 100644
--- a/homeassistant/components/zwave_js/services.py
+++ b/homeassistant/components/zwave_js/services.py
@@ -92,6 +92,11 @@ def _async_register_credential_services(hass: HomeAssistant) -> None:
CREDENTIAL_RULE_REVERSE_MAP.keys()
),
vol.Optional(const.ATTR_USER_ACTIVE): cv.boolean,
+ vol.Inclusive(const.ATTR_CREDENTIAL_TYPE, "credential"): vol.In(
+ const.WRITABLE_CREDENTIAL_TYPES
+ ),
+ vol.Optional(const.ATTR_CREDENTIAL_SLOT): uint16_id,
+ vol.Inclusive(const.ATTR_CREDENTIAL_DATA, "credential"): cv.string,
},
func="async_set_user",
supports_response=SupportsResponse.ONLY,
diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml
index dc1bbbdc1aef..4cb2b6a214b8 100644
--- a/homeassistant/components/zwave_js/services.yaml
+++ b/homeassistant/components/zwave_js/services.yaml
@@ -129,6 +129,22 @@ set_user:
active:
selector:
boolean:
+ credential_type:
+ selector:
+ select:
+ options:
+ - pin_code
+ - password
+ credential_slot:
+ selector:
+ number:
+ min: 1
+ max: 65535
+ step: 1
+ mode: box
+ credential_data:
+ selector:
+ text:
delete_user:
target:
diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json
index 95536733c869..e7f13d332e74 100644
--- a/homeassistant/components/zwave_js/strings.json
+++ b/homeassistant/components/zwave_js/strings.json
@@ -30,7 +30,7 @@
"invalid_ws_url": "Invalid websocket URL",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
- "flow_title": "Network {home_id} at {host}:{port}",
+ "flow_title": "{name}",
"progress": {
"backup_nvm": "Please wait while the network backup completes",
"install_addon": "Installation can take several minutes",
@@ -322,6 +322,9 @@
"credential_rejected_wrong_uuid": {
"message": "The device rejected the credential because the user unique identifier does not match."
},
+ "credential_required": {
+ "message": "This device requires a credential when adding a user."
+ },
"credential_type_not_supported": {
"message": "Credential type {credential_type} is not supported on this device"
},
@@ -800,16 +803,28 @@
"name": "Set lock user code"
},
"set_user": {
- "description": "Creates or updates an access-control user on a lock entity. Returns the allocated user_id for each targeted lock entity.",
+ "description": "Creates or updates an access-control user on a lock entity. When no user_id is given, a new user is created. A credential (PIN code or password) can be written in the same call. Returns the allocated user_id, plus the credential_slot when a credential was written, for each targeted lock entity.",
"fields": {
"active": {
"description": "Whether the user is active.",
"name": "Active"
},
+ "credential_data": {
+ "description": "The credential data (e.g. PIN digits or password string).",
+ "name": "Credential data"
+ },
"credential_rule": {
"description": "Credential rule for the user (single, dual, or triple).",
"name": "Credential rule"
},
+ "credential_slot": {
+ "description": "Credential slot index. If not specified, the first available slot is used.",
+ "name": "Credential slot"
+ },
+ "credential_type": {
+ "description": "The type of credential (pin_code or password).",
+ "name": "Credential type"
+ },
"user_id": {
"description": "User slot index. If not specified, the first available slot is used.",
"name": "User index"
diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py
index 91b3559f4466..7dd68ff7e3c1 100644
--- a/homeassistant/components/zwave_js/update.py
+++ b/homeassistant/components/zwave_js/update.py
@@ -19,11 +19,11 @@ from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.node.firmware import NodeFirmwareUpdateInfo
from homeassistant.components.update import (
- ATTR_LATEST_VERSION,
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
+ UpdateEntityStateAttribute,
)
from homeassistant.const import EntityCategory
from homeassistant.core import CoreState, HomeAssistant, callback
@@ -355,7 +355,11 @@ class ZWaveFirmwareUpdateEntity(ZWaveNodeBaseEntity, UpdateEntity):
# If we have a complete previous state, use that to set the latest version
if (
(state := await self.async_get_last_state())
- and (latest_version := state.attributes.get(ATTR_LATEST_VERSION))
+ and (
+ latest_version := state.attributes.get(
+ UpdateEntityStateAttribute.LATEST_VERSION
+ )
+ )
is not None
and (extra_data := await self.async_get_last_extra_data())
and (
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index 2edbb3c035f8..9f1e2839be95 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -2133,6 +2133,7 @@ class ConfigEntries:
self._hass_config = hass_config
self._entries = ConfigEntryItems(hass)
self._store = ConfigEntryStore(hass)
+ self._initialized = asyncio.Event()
EntityRegistryDisabledHandler(hass).async_setup()
@callback
@@ -2277,7 +2278,7 @@ class ConfigEntries:
dev_reg = dr.async_get(self.hass)
ent_reg = er.async_get(self.hass)
- dev_reg.async_clear_config_entry(entry_id)
+ dev_reg.async_clear_config_entry(entry_id, entry.domain)
ent_reg.async_clear_config_entry(entry_id)
# If the configuration entry is removed during reauth, it should
@@ -2302,6 +2303,7 @@ class ConfigEntries:
if config is None:
self._entries = ConfigEntryItems(self.hass)
+ self._initialized.set()
return
entries: ConfigEntryItems = ConfigEntryItems(self.hass)
@@ -2341,6 +2343,12 @@ class ConfigEntries:
EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries
)
+ self._initialized.set()
+
+ async def async_wait_initialized(self) -> None:
+ """Wait until the config entries are loaded from storage."""
+ await self._initialized.wait()
+
async def _async_scan_orphan_ignored_entries(
self, event: Event[NoEventData]
) -> None:
@@ -2686,7 +2694,7 @@ class ConfigEntries:
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)
+ dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id, entry.domain)
ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id)
return result
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 326742e062e1..346f9a37f58b 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -48,6 +48,9 @@ ENTITY_MATCH_ANY: Final = "any"
# If no name is specified
DEVICE_DEFAULT_NAME: Final = "Unnamed Device"
+# Default radius of the Home Zone (in meters)
+DEFAULT_RADIUS: Final = 100
+
# Max characters for data stored in the recorder (changes to these limits would require
# a database migration)
MAX_LENGTH_EVENT_EVENT_TYPE: Final = 64
@@ -471,6 +474,8 @@ class EntityStateAttribute(StrEnum):
ENTITY_PICTURE = "entity_picture"
FRIENDLY_NAME = "friendly_name"
ICON = "icon"
+ LATITUDE = "latitude"
+ LONGITUDE = "longitude"
RESTORED = "restored"
SUPPORTED_FEATURES = "supported_features"
UNIT_OF_MEASUREMENT = "unit_of_measurement"
diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py
index de958b97d417..82413efcc721 100644
--- a/homeassistant/core_config.py
+++ b/homeassistant/core_config.py
@@ -49,6 +49,7 @@ from .const import (
CONF_UNIT_SYSTEM,
CONF_URL,
CONF_USERNAME,
+ DEFAULT_RADIUS,
EVENT_CORE_CONFIG_UPDATE,
KEY_DATA_LOGGING_DISABLED_REASON,
LEGACY_CONF_WHITELIST_EXTERNAL_DIRS,
@@ -538,8 +539,6 @@ class Config:
def __init__(self, hass: HomeAssistant, config_dir: str) -> None:
"""Initialize a new config object."""
- from .components.zone import DEFAULT_RADIUS # noqa: PLC0415
-
self.hass = hass
self.latitude: float = 0
@@ -858,9 +857,6 @@ class Config:
old_data: dict[str, Any],
) -> dict[str, Any]:
"""Migrate to the new version."""
-
- from .components.zone import DEFAULT_RADIUS # noqa: PLC0415
-
data = old_data
if old_major_version == 1 and old_minor_version < 2:
# In 1.2, we remove support for "imperial", replaced by "us_customary"
diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py
index 06ebd9b263f9..06fe7da634be 100644
--- a/homeassistant/generated/application_credentials.py
+++ b/homeassistant/generated/application_credentials.py
@@ -15,6 +15,7 @@ APPLICATION_CREDENTIALS = [
"google",
"google_assistant_sdk",
"google_drive",
+ "google_health",
"google_mail",
"google_photos",
"google_sheets",
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 789334a9d9fd..5ef4c22897d6 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -74,6 +74,7 @@ FLOWS = {
"aprilaire",
"apsystems",
"aquacell",
+ "aqualogic",
"aqvify",
"aranet",
"arcam_fmj",
@@ -256,12 +257,14 @@ FLOWS = {
"fritzbox_callmonitor",
"fronius",
"frontier_silicon",
+ "fuelprices_dk",
"fujitsu_fglair",
"fully_kiosk",
"fumis",
"fyta",
"garages_amsterdam",
"gardena_bluetooth",
+ "gatus",
"gdacs",
"generic",
"geniushub",
@@ -284,6 +287,7 @@ FLOWS = {
"google_cloud",
"google_drive",
"google_generative_ai_conversation",
+ "google_health",
"google_mail",
"google_photos",
"google_sheets",
@@ -303,6 +307,8 @@ FLOWS = {
"guntamatic",
"habitica",
"hanna",
+ "harbor",
+ "harman_luxury",
"harmony",
"hdfury",
"hegel",
@@ -385,6 +391,7 @@ FLOWS = {
"justnimbus",
"jvc_projector",
"kaleidescape",
+ "karakeep",
"keenetic_ndms2",
"kegtron",
"keymitt_ble",
@@ -408,6 +415,7 @@ FLOWS = {
"ld2410_ble",
"leaone",
"led_ble",
+ "led_infrared",
"lektrico",
"letpot",
"lg_infrared",
@@ -422,8 +430,10 @@ FLOWS = {
"lifx",
"linkplay",
"litejet",
+ "litellm",
"litterrobot",
"livisi",
+ "llama_cpp",
"local_calendar",
"local_file",
"local_ip",
@@ -433,11 +443,13 @@ FLOWS = {
"london_underground",
"lookin",
"loqed",
+ "luci",
"luftdaten",
"lunatone",
"lupusec",
"lutron",
"lutron_caseta",
+ "lyngdorf",
"lyric",
"madvr",
"mailgun",
@@ -496,6 +508,7 @@ FLOWS = {
"nasweb",
"neato",
"nederlandse_spoorwegen",
+ "neopool",
"ness_alarm",
"nest",
"netatmo",
@@ -573,7 +586,6 @@ FLOWS = {
"peblar",
"peco",
"pegel_online",
- "permobil",
"pglab",
"philips_js",
"pi_hole",
@@ -663,6 +675,7 @@ FLOWS = {
"satel_integra",
"saunum",
"schlage",
+ "scorpiontrack",
"scrape",
"screenlogic",
"season",
diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py
index 0cb446fd2c92..2c7dba6202db 100644
--- a/homeassistant/generated/dhcp.py
+++ b/homeassistant/generated/dhcp.py
@@ -1058,6 +1058,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "my[45]50*",
"macaddress": "001E0C*",
},
+ {
+ "domain": "stiebel_eltron",
+ "hostname": "servicewelt*",
+ },
{
"domain": "sunricher_dali",
"registered_devices": True,
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 58949fe0594e..5b860e42de22 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -499,7 +499,7 @@
"aqualogic": {
"name": "AquaLogic",
"integration_type": "hub",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "local_push"
},
"aquostv": {
@@ -2319,6 +2319,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "fuelprices_dk": {
+ "name": "Fuelprices.dk",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"fujitsu": {
"name": "Fujitsu",
"integrations": {
@@ -2382,6 +2388,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "gatus": {
+ "name": "Gatus",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"gaviota": {
"name": "Gaviota",
"integration_type": "virtual",
@@ -2554,6 +2566,12 @@
"iot_class": "cloud_polling",
"name": "Google Gemini"
},
+ "google_health": {
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "Google Health"
+ },
"google_mail": {
"integration_type": "service",
"config_flow": true,
@@ -2740,6 +2758,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "harbor": {
+ "name": "Harbor Sleep",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"hardkernel": {
"name": "Hardkernel",
"integration_type": "hardware",
@@ -2752,6 +2776,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "harman_luxury": {
+ "name": "Harman Luxury Audio",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"harvey": {
"name": "Harvey",
"integration_type": "virtual",
@@ -3482,6 +3512,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "karakeep": {
+ "name": "Karakeep",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"keba": {
"name": "Keba Charging Station",
"integration_type": "hub",
@@ -3698,6 +3734,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "led_infrared": {
+ "name": "LED Infrared",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "assumed_state"
+ },
"legrand": {
"name": "Legrand",
"integration_type": "virtual",
@@ -3875,6 +3917,12 @@
"iot_class": "local_push",
"single_config_entry": true
},
+ "litellm": {
+ "name": "LiteLLM",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"litterrobot": {
"name": "Whisker",
"integration_type": "hub",
@@ -3887,6 +3935,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "llama_cpp": {
+ "name": "llama.cpp",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"llamalab_automate": {
"name": "LlamaLab Automate",
"integration_type": "hub",
@@ -4021,6 +4075,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "lyngdorf": {
+ "name": "Lyngdorf",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"madeco": {
"name": "Madeco",
"integration_type": "virtual",
@@ -4133,12 +4193,6 @@
"config_flow": false,
"iot_class": "local_polling"
},
- "melcloud_home": {
- "name": "MELCloud Home",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "cloud_polling"
- },
"melissa": {
"name": "Melissa",
"integration_type": "hub",
@@ -4332,6 +4386,12 @@
"iot_class": "cloud_polling",
"name": "MELCloud"
},
+ "melcloud_home": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "MELCloud Home"
+ },
"mitsubishi_comfort": {
"integration_type": "hub",
"config_flow": true,
@@ -4364,6 +4424,7 @@
"iot_class": "local_polling"
},
"modbus": {
+ "name": "Modbus",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling"
@@ -4618,6 +4679,12 @@
"integration_type": "virtual",
"supported_by": "shelly"
},
+ "neopool": {
+ "name": "NeoPool",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"ness_alarm": {
"name": "Ness Alarm",
"integration_type": "hub",
@@ -4628,7 +4695,8 @@
"name": "Netatmo",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "single_config_entry": true
},
"netdata": {
"name": "Netdata",
@@ -5092,7 +5160,7 @@
"integrations": {
"luci": {
"integration_type": "hub",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "local_polling",
"name": "OpenWrt (luci)"
},
@@ -5289,7 +5357,7 @@
"permobil": {
"name": "MyPermobil",
"integration_type": "device",
- "config_flow": true,
+ "config_flow": false,
"iot_class": "cloud_polling"
},
"pge": {
@@ -6160,6 +6228,12 @@
"integration_type": "virtual",
"supported_by": "opower"
},
+ "scorpiontrack": {
+ "name": "ScorpionTrack",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"scrape": {
"name": "Scrape",
"integration_type": "hub",
@@ -8487,7 +8561,6 @@
"local_todo",
"min_max",
"mobile_app",
- "modbus",
"moehlenhoff_alpha2",
"mold_indicator",
"moon",
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
index ecb94f5d1e1f..8409d1d8f436 100644
--- a/homeassistant/generated/ssdp.py
+++ b/homeassistant/generated/ssdp.py
@@ -135,6 +135,16 @@ SSDP = {
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1",
},
],
+ "harman_luxury": [
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
+ "manufacturer": "Harman Luxury Audio",
+ },
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
+ "manufacturer": "Harman Luxury Audio",
+ },
+ ],
"harmony": [
{
"deviceType": "urn:myharmony-com:device:harmony:1",
@@ -206,6 +216,12 @@ SSDP = {
"deviceType": "urn:schemas-upnp-org:device:LaMetric:1",
},
],
+ "lyngdorf": [
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
+ "manufacturer": "Lyngdorf",
+ },
+ ],
"nanoleaf": [
{
"st": "Nanoleaf_aurora:light",
diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py
index c775a4aed728..8ab868a35b5e 100644
--- a/homeassistant/helpers/area_registry.py
+++ b/homeassistant/helpers/area_registry.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal, TypedDict, override
-from homeassistant.const import ATTR_DEVICE_CLASS
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.dt import utc_from_timestamp, utcnow
from homeassistant.util.event_type import EventType
@@ -581,7 +581,8 @@ def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None:
if (
state.domain != "sensor"
- or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.TEMPERATURE
+ or state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
+ != SensorDeviceClass.TEMPERATURE
):
raise ValueError(f"Entity {entity_id} is not a temperature sensor")
@@ -595,6 +596,7 @@ def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None:
if (
state.domain != "sensor"
- or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY
+ or state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
+ != SensorDeviceClass.HUMIDITY
):
raise ValueError(f"Entity {entity_id} is not a humidity sensor")
diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py
index 212ecf02f380..4896cf59b498 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -3,8 +3,7 @@
import abc
import asyncio
from collections import deque
-from collections.abc import Callable, Container, Coroutine, Generator, Iterable, Mapping
-from contextlib import contextmanager
+from collections.abc import Callable, Container, Coroutine, Iterable, Mapping
from dataclasses import dataclass
from datetime import datetime, time as dt_time, timedelta
import functools as ft
@@ -31,8 +30,6 @@ from typing import (
import voluptuous as vol
from homeassistant.const import (
- ATTR_DEVICE_CLASS,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_ABOVE,
CONF_AFTER,
CONF_ATTRIBUTE,
@@ -51,13 +48,21 @@ from homeassistant.const import (
CONF_TARGET,
CONF_VALUE_TEMPLATE,
CONF_WEEKDAY,
+ CONF_ZONE,
ENTITY_MATCH_ALL,
ENTITY_MATCH_ANY,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
WEEKDAYS,
+ EntityStateAttribute,
+)
+from homeassistant.core import (
+ HomeAssistant,
+ State,
+ callback,
+ split_entity_id,
+ valid_entity_id,
)
-from homeassistant.core import HomeAssistant, State, callback, split_entity_id
from homeassistant.exceptions import (
ConditionError,
ConditionErrorContainer,
@@ -989,7 +994,7 @@ class EntityNumericalConditionBase(EntityConditionBase):
# Entity not found
return None
if not self._is_valid_unit(
- entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
):
# Entity unit does not match the expected unit
return None
@@ -1007,7 +1012,7 @@ class EntityNumericalConditionBase(EntityConditionBase):
domain_spec = self._domain_specs[entity_state.domain]
if domain_spec.value_source is None:
if not self._is_valid_unit(
- entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
):
return None
return entity_state.state
@@ -1095,7 +1100,7 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase):
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the unit of an entity from its state."""
- return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ return entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
@override
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None:
@@ -1121,7 +1126,7 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase):
try:
return self._unit_converter.convert(
value,
- entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT),
+ entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT),
self._base_unit,
)
except HomeAssistantError:
@@ -1228,25 +1233,42 @@ def condition_trace_update_result(**kwargs: Any) -> None:
node.update_result(**kwargs)
-@contextmanager
-def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement]:
+class trace_condition:
"""Trace condition evaluation."""
- should_pop = True
- trace_element = trace_stack_top(trace_stack_cv)
- if trace_element and trace_element.reuse_by_child:
- should_pop = False
- trace_element.reuse_by_child = False
- else:
- trace_element = condition_trace_append(variables, trace_path_get())
- trace_stack_push(trace_stack_cv, trace_element)
- try:
- yield trace_element
- except Exception as ex:
- trace_element.set_error(ex)
- raise
- finally:
- if should_pop:
- trace_stack_pop(trace_stack_cv)
+
+ __slots__ = ("_should_pop", "_trace_element", "_variables")
+
+ _should_pop: bool
+ _trace_element: TraceElement
+
+ def __init__(self, variables: TemplateVarsType) -> None:
+ """Store the variables for the trace element."""
+ self._variables = variables
+
+ def __enter__(self) -> TraceElement:
+ """Start tracing the condition evaluation."""
+ should_pop = True
+ trace_element = trace_stack_top(trace_stack_cv)
+ if trace_element and trace_element.reuse_by_child:
+ should_pop = False
+ trace_element.reuse_by_child = False
+ else:
+ trace_element = condition_trace_append(self._variables, trace_path_get())
+ trace_stack_push(trace_stack_cv, trace_element)
+ self._should_pop = should_pop
+ self._trace_element = trace_element
+ return trace_element
+
+ def __exit__(
+ self, exc_type: object, exc_val: BaseException | None, exc_tb: object
+ ) -> None:
+ """Finish tracing the condition evaluation."""
+ try:
+ if exc_val is not None and isinstance(exc_val, Exception):
+ self._trace_element.set_error(exc_val)
+ finally:
+ if self._should_pop:
+ trace_stack_pop(trace_stack_cv)
@overload
@@ -1851,7 +1873,7 @@ def time(
):
after = datetime.strptime(after_entity.state, "%H:%M:%S").time()
elif (
- after_entity.attributes.get(ATTR_DEVICE_CLASS)
+ after_entity.attributes.get(EntityStateAttribute.DEVICE_CLASS)
in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME)
) and after_entity.state not in (
STATE_UNAVAILABLE,
@@ -1881,7 +1903,7 @@ def time(
except ValueError:
return False
elif (
- before_entity.attributes.get(ATTR_DEVICE_CLASS)
+ before_entity.attributes.get(EntityStateAttribute.DEVICE_CLASS)
in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME)
) and before_entity.state not in (
STATE_UNAVAILABLE,
@@ -2120,6 +2142,27 @@ def async_extract_entities(config: ConfigType | Template) -> set[str]:
to_process.extend(config["conditions"])
continue
+ if condition == "time":
+ # The before and after options can be a time or an entity id.
+ for key in (CONF_AFTER, CONF_BEFORE):
+ if isinstance(value := config.get(key), str) and valid_entity_id(value):
+ referenced.add(value)
+ continue
+
+ if condition == "zone":
+ options = config.get(CONF_OPTIONS, {})
+ referenced.update(options.get(CONF_ENTITY_ID, []))
+ referenced.update(options.get(CONF_ZONE, []))
+
+ elif condition in (
+ "zone.in_zone",
+ "zone.not_in_zone",
+ "zone.occupancy_is_detected",
+ "zone.occupancy_is_not_detected",
+ ):
+ if zone_entity_id := config.get(CONF_OPTIONS, {}).get(CONF_ZONE):
+ referenced.add(zone_entity_id)
+
entity_ids = config.get(CONF_ENTITY_ID)
if isinstance(entity_ids, str):
diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py
index 2d90a9c7914b..af8e5908661f 100644
--- a/homeassistant/helpers/device.py
+++ b/homeassistant/helpers/device.py
@@ -3,6 +3,7 @@
from homeassistant.core import HomeAssistant, callback
from . import device_registry as dr, entity_registry as er
+from .frame import ReportBehavior, report_usage
@callback
@@ -41,13 +42,18 @@ def async_device_info_to_link_from_entity(
) -> dr.DeviceInfo | None:
"""DeviceInfo with information to link a device from an entity.
- DeviceInfo will only return information to categorize as a link.
+ Deprecated, always returns None; set entity.device_entry instead.
"""
-
- return async_device_info_to_link_from_device_id(
- hass,
- async_entity_id_to_device_id(hass, entity_id_or_uuid),
+ report_usage(
+ "calls async_device_info_to_link_from_entity, which is deprecated and always "
+ "returns None: a device_info carrying another device's identifiers implicitly "
+ "added the caller's config entry to that device, which a single-config-entry "
+ "device can't represent. Set entity.device_entry = "
+ "async_entity_id_to_device(hass, source_entity_id) instead",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2027.8.0",
)
+ return None
@callback
@@ -57,18 +63,17 @@ def async_device_info_to_link_from_device_id(
) -> dr.DeviceInfo | None:
"""DeviceInfo with information to link a device from a device id.
- DeviceInfo will only return information to categorize as a link.
+ Deprecated, always returns None; set entity.device_entry instead.
"""
-
- dev_reg = dr.async_get(hass)
-
- if device_id is None or (device := dev_reg.async_get(device_id=device_id)) is None:
- return None
-
- return dr.DeviceInfo(
- identifiers=device.identifiers,
- connections=device.connections,
+ report_usage(
+ "calls async_device_info_to_link_from_device_id, which is deprecated and always "
+ "returns None: a device_info carrying another device's identifiers implicitly "
+ "added the caller's config entry to that device, which a single-config-entry "
+ "device can't represent. Set entity.device_entry to the target device instead",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2027.8.0",
)
+ return None
@callback
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index daf373a18624..10c015f5520d 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -2,11 +2,15 @@
import asyncio
from collections import defaultdict
-from collections.abc import Iterable, Mapping
+from collections.abc import Iterable, Mapping, Set as AbstractSet
+import copy
+from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from functools import lru_cache
import logging
+import os
+import shutil
import time
from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack, override
@@ -32,7 +36,12 @@ from homeassistant.util.json import format_unserializable_data
from . import storage, translation
from .debounce import Debouncer
from .deprecation import deprecated_function
-from .frame import ReportBehavior, report_usage
+from .frame import (
+ MissingIntegrationFrame,
+ ReportBehavior,
+ get_integration_frame,
+ report_usage,
+)
from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment
from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType
from .typing import UNDEFINED, UndefinedType
@@ -54,8 +63,8 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event
"device_registry_updated"
)
STORAGE_KEY = "core.device_registry"
-STORAGE_VERSION_MAJOR = 1
-STORAGE_VERSION_MINOR = 12
+STORAGE_VERSION_MAJOR = 3
+STORAGE_VERSION_MINOR = 1
CLEANUP_DELAY = 10
@@ -66,8 +75,32 @@ CONNECTION_ZIGBEE = "zigbee"
ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30
-# Can be removed when suggested_area is removed from DeviceEntry
-RUNTIME_ONLY_ATTRS = {"suggested_area"}
+# suggested_area can be removed when suggested_area is removed from DeviceEntry.
+# pending_move can be removed once add_config_entry_id and remove_config_entry_id
+# are removed from the device registry API.
+RUNTIME_ONLY_ATTRS = {"suggested_area", "pending_move"}
+
+
+@dataclass(frozen=True, slots=True)
+class _PendingMove:
+ """A deferred config-entry move recorded by add_config_entry_id.
+
+ A later remove_config_entry_id from the same integration (origin_domain) completes
+ the move; one from a different integration cancels it. Runtime-only, never stored.
+ """
+
+ config_entry_id: str
+ config_subentry_id: str | None
+ origin_domain: str | None
+
+
+def _current_integration_domain() -> str | None:
+ """Return the domain of the integration in the current call stack, if any."""
+ try:
+ return get_integration_frame().integration
+ except MissingIntegrationFrame:
+ return None
+
CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"}
@@ -102,7 +135,8 @@ class DeviceInfo(TypedDict, total=False):
hw_version: str | None
translation_key: str | None
translation_placeholders: Mapping[str, str] | None
- via_device: tuple[str, str]
+ via_device: tuple[str, str] # Deprecated, use via_device_id instead
+ via_device_id: str
DEVICE_INFO_TYPES = {
@@ -127,6 +161,7 @@ DEVICE_INFO_TYPES = {
"suggested_area",
"sw_version",
"via_device",
+ "via_device_id",
},
"secondary": {
"connections",
@@ -135,14 +170,10 @@ DEVICE_INFO_TYPES = {
"default_name",
# Used by Fritz
"via_device",
+ "via_device_id",
},
}
-DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values()))
-
-# Integrations which may share a device with a native integration
-LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"}
-
class _EventDeviceRegistryUpdatedData_Create(TypedDict):
"""EventDeviceRegistryUpdated data for action type 'create'."""
@@ -365,9 +396,10 @@ def _normalize_connections_validator(
class DeviceEntry:
"""Device Registry Entry."""
+ config_entry_id: str = attr.ib()
+
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)
+ config_subentry_id: str | None = attr.ib(default=None)
configuration_url: str | None = attr.ib(default=None)
connections: set[tuple[str, str]] = attr.ib(
converter=set, factory=set, validator=_normalize_connections_validator
@@ -379,20 +411,85 @@ class DeviceEntry:
id: str = attr.ib(factory=uuid_util.random_uuid_hex)
identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set)
labels: set[str] = attr.ib(converter=set, factory=set)
+ # composite_device_id is the id of the pre-migration composite device this device was
+ # split from; composite_primary_config_entry is that composite's former
+ # primary_config_entry, so a restored composite device can report it.
+ # split_at records when the split happened.
+ composite_device_id: str | None = attr.ib(default=None)
+ composite_primary_config_entry: str | None = attr.ib(default=None)
+ split_at: datetime | None = attr.ib(default=None)
manufacturer: str | None = attr.ib(default=None)
model: str | None = attr.ib(default=None)
model_id: str | None = attr.ib(default=None)
modified_at: datetime = attr.ib(factory=utcnow)
name_by_user: str | None = attr.ib(default=None)
name: str | None = attr.ib(default=None)
- primary_config_entry: str | None = attr.ib(default=None)
+ # Set on devices created by splitting a pre-migration composite device: the
+ # identifiers and connections copied from the composite have not yet been reconciled.
+ # On the owning integration's first re-registration they are replaced with the ones
+ # it provides and this flag is cleared - a one-shot marker, unlike composite_device_id
+ # which is kept for the device's lifetime so old ids keep resolving; neither can be
+ # derived from the other. This flag and the replacement logic can be removed in HA
+ # Core 2027.8.
+ has_composite_identifiers: bool = attr.ib(default=False)
serial_number: str | None = attr.ib(default=None)
- # Suggested area is deprecated and will be removed from DeviceEntry in 2026.9.
+ # Suggested area is deprecated and will be removed from DeviceEntry in HA Core 2026.9.
_suggested_area: str | None = attr.ib(default=None)
sw_version: str | None = attr.ib(default=None)
via_device_id: str | None = attr.ib(default=None)
+ # Transient pending move target (config_entry_id, config_subentry_id) initiated by
+ # add_config_entry_id and completed by a subsequent remove_config_entry_id. It is
+ # never stored and is not part of equality. Can be removed in HA Core 2027.8.
+ _pending_move: _PendingMove | None = attr.ib(default=None, eq=False)
+ # Set only on the read-only composite device that async_get synthesizes on demand
+ # for a pre-migration composite device id. It holds the union of the split
+ # devices' config entries and subentries so callers see the pre-split device. It is
+ # never stored and the composite is never added to the registry. Can be removed in
+ # HA Core 2027.8.
+ _composite_subentries: dict[str, set[str | None]] | None = attr.ib(
+ default=None, eq=False
+ )
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
+ @property
+ def config_entries(self) -> set[str]:
+ """Return the config entries this device belongs to.
+
+ Deprecated compatibility shim: a device now belongs to a single config
+ entry, available as config_entry_id.
+ """
+ if self._composite_subentries is not None:
+ return set(self._composite_subentries)
+ return {self.config_entry_id}
+
+ @property
+ def config_entries_subentries(self) -> dict[str, set[str | None]]:
+ """Return the config subentries this device belongs to.
+
+ Deprecated compatibility shim: a device now belongs to a single config
+ entry and subentry, available as config_entry_id and config_subentry_id.
+ """
+ if self._composite_subentries is not None:
+ return {
+ entry_id: set(subentries)
+ for entry_id, subentries in self._composite_subentries.items()
+ }
+ return {self.config_entry_id: {self.config_subentry_id}}
+
+ @property
+ def primary_config_entry(self) -> str:
+ """Return the primary config entry of this device.
+
+ Deprecated compatibility shim: a device now belongs to a single config
+ entry, available as config_entry_id, which is its primary config entry.
+
+ For a restored composite device (synthesized on the fly by async_get for a
+ pre-migration composite device id), this returns the composite's former
+ primary_config_entry, which is recorded on the split devices during migration as
+ composite_primary_config_entry.
+ """
+ return self.config_entry_id
+
@property
def disabled(self) -> bool:
"""Return if entry is disabled."""
@@ -407,11 +504,16 @@ class DeviceEntry:
return {
"area_id": self.area_id,
"configuration_url": self.configuration_url,
+ # config_entries and config_entries_subentries are deprecated and kept for
+ # backwards compatibility, they can be removed in HA Core 2027.8. They use the
+ # compatibility properties so a restored composite reports its merged entries.
"config_entries": list(self.config_entries),
"config_entries_subentries": {
entry_id: list(subentries)
for entry_id, subentries in self.config_entries_subentries.items()
},
+ "config_entry_id": self.config_entry_id,
+ "config_subentry_id": self.config_subentry_id,
"connections": list(self.connections),
"created_at": self.created_at.timestamp(),
"disabled_by": self.disabled_by,
@@ -455,15 +557,8 @@ 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": {
- entry_id: list(subentries)
- for entry_id, subentries in (
- self.config_entries_subentries.items()
- )
- },
+ "config_entry_id": self.config_entry_id,
+ "config_subentry_id": self.config_subentry_id,
"configuration_url": self.configuration_url,
"connections": list(self.connections),
"created_at": self.created_at,
@@ -473,12 +568,18 @@ class DeviceEntry:
"id": self.id,
"identifiers": list(self.identifiers),
"labels": list(self.labels),
+ "composite_device_id": self.composite_device_id,
+ "composite_primary_config_entry": (
+ self.composite_primary_config_entry
+ ),
+ "split_at": self.split_at,
"manufacturer": self.manufacturer,
"model": self.model,
"model_id": self.model_id,
"modified_at": self.modified_at,
"name_by_user": self.name_by_user,
"name": self.name,
+ "has_composite_identifiers": (self.has_composite_identifiers),
"primary_config_entry": self.primary_config_entry,
"serial_number": self.serial_number,
"sw_version": self.sw_version,
@@ -496,13 +597,32 @@ class DeviceEntry:
return self._suggested_area
+# async_update_device arguments that redefine which identifiers/connections a device is
+# keyed by, or move it to another config entry. They are ambiguous on a synthesized
+# composite (there is no single underlying device to retarget), so the composite shim
+# drops them with a warning instead of fanning them out. serial_number is intentionally
+# NOT here: it describes the physical device and is consistent across a composite's
+# splits, so it fans out like sw_version. Can be removed in HA Core 2027.8.
+_COMPOSITE_IGNORED_UPDATE_ARGS = (
+ "merge_connections",
+ "merge_identifiers",
+ "new_config_entry_id",
+ "new_config_subentry_id",
+ "new_connections",
+ "new_identifiers",
+)
+
+
@attr.s(frozen=True, slots=True)
class DeletedDeviceEntry:
"""Deleted Device Registry Entry."""
+ # config_entry_id is None for orphaned deleted devices, i.e. devices whose owning
+ # config entry has been removed
+ config_entry_id: str | None = attr.ib()
+ config_subentry_id: str | None = attr.ib()
+
area_id: str | None = attr.ib()
- config_entries: set[str] = attr.ib()
- config_entries_subentries: dict[str, set[str | None]] = attr.ib()
connections: set[tuple[str, str]] = attr.ib(
validator=_normalize_connections_validator
)
@@ -514,8 +634,30 @@ class DeletedDeviceEntry:
modified_at: datetime = attr.ib()
name_by_user: str | None = attr.ib()
orphaned_timestamp: float | None = attr.ib()
+ # Domain of the config entry that owns (or owned) this device, recorded when the
+ # device is deleted so a re-added config entry only restores an orphan from the same
+ # integration. None for legacy stores.
+ domain: str | None = attr.ib(default=None)
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
+ @property
+ def config_entries(self) -> set[str]:
+ """Return the config entries this device belonged to.
+
+ Deprecated compatibility shim; empty for orphaned deleted devices.
+ """
+ return {self.config_entry_id} if self.config_entry_id is not None else set()
+
+ @property
+ def config_entries_subentries(self) -> dict[str, set[str | None]]:
+ """Return the config subentries this device belonged to.
+
+ Deprecated compatibility shim; empty for orphaned deleted devices.
+ """
+ if self.config_entry_id is None:
+ return {}
+ return {self.config_entry_id: {self.config_subentry_id}}
+
def to_device_entry(
self,
config_entry: ConfigEntry,
@@ -537,9 +679,9 @@ class DeletedDeviceEntry:
disabled_by = disabled_by if disabled_by is not UNDEFINED else None
return DeviceEntry(
area_id=self.area_id,
+ config_entry_id=config_entry.entry_id,
+ config_subentry_id=config_subentry_id,
# type ignores: likely https://github.com/python/mypy/issues/8625
- config_entries={config_entry.entry_id}, # type: ignore[arg-type]
- config_entries_subentries={config_entry.entry_id: {config_subentry_id}},
connections=self.connections & connections, # type: ignore[arg-type]
created_at=self.created_at,
disabled_by=disabled_by,
@@ -556,15 +698,8 @@ class DeletedDeviceEntry:
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": {
- entry_id: list(subentries)
- for entry_id, subentries in (
- self.config_entries_subentries.items()
- )
- },
+ "config_entry_id": self.config_entry_id,
+ "config_subentry_id": self.config_subentry_id,
"connections": list(self.connections),
"created_at": self.created_at,
"disabled_by": self.disabled_by
@@ -577,11 +712,23 @@ class DeletedDeviceEntry:
"modified_at": self.modified_at,
"name_by_user": self.name_by_user,
"orphaned_timestamp": self.orphaned_timestamp,
+ "domain": self.domain,
}
)
)
+def _copy_if_exists(source: str, destination: str) -> bool:
+ """Copy source to destination when source exists (runs in the executor).
+
+ Returns whether the file was copied.
+ """
+ if not os.path.isfile(source):
+ return False
+ shutil.copyfile(source, destination)
+ return True
+
+
class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
"""Store entity registry data."""
@@ -593,10 +740,12 @@ 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."""
- # 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.
+ # Note: There's no version 2, it was planned and supported by previous versions
+ # of the migrator which treated version 2 like version 1.
if old_major_version < 3:
+ # Copy the store before the version 3 migrator rewrites every device, so a
+ # user can recover the pre-migration registry if the migration misbehaves.
+ await self._async_backup_store()
if old_minor_version < 2:
# Version 1.2 implements migration and freezes the available keys,
# populate keys which were introduced before version 1.2
@@ -677,80 +826,277 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
# of version 1.10
for device in old_data["deleted_devices"]:
device["disabled_by_undefined"] = old_minor_version < 10
+ # Version 3 restricts a device to a single config entry and subentry,
+ # introduced in 2026.8. Composite devices which belonged to several
+ # config entries (or several subentries of one entry) are split into one
+ # device per (config entry, subentry). Each split device keeps a copy of
+ # the identifiers and connections and a reference (composite_device_id) to the original
+ # composite device id, so that actions targeting the old id still reach
+ # all split devices. Entities are moved to the matching split device when
+ # the registries are loaded.
+ migrated_at = utcnow().isoformat()
+ devices: list[dict[str, Any]] = []
+ # Ids of active devices dropped for lacking a config entry; a retained
+ # child's via_device_id pointing at one is detached below.
+ dropped_device_ids: set[str] = set()
+ # old composite id -> {config entry id -> new split id}, to rewrite
+ # via_device_id links pointing at a split parent
+ composite_splits: dict[str, dict[str, str]] = {}
+ # Active splits whose copied disabled_by must be reconciled against their
+ # single config entry once the config entries are loaded
+ migrated_active_splits: list[dict[str, Any]] = []
+ for device in old_data["devices"]:
+ # One target per config entry. config_entries_subentries was a set, so
+ # the old model allowed a device in several subentries of one config
+ # entry, but the single-owner model keeps one. Multi-subentry devices
+ # created by core integrations all come from broken subentry migrators
+ # (which left a device in both None and its real subentry), so prefer
+ # a real subentry over the main entry (None). Collapsing rather than
+ # splitting avoids duplicate devices which, sharing identifiers and
+ # connections within one config entry, would collide in the
+ # per-config-entry identifier/connection index.
+ pairs = [
+ (
+ config_entry_id,
+ next((s for s in subentry_ids if s is not None), None),
+ )
+ for config_entry_id, subentry_ids in device[
+ "config_entries_subentries"
+ ].items()
+ ]
+ if not pairs:
+ # Drop devices that have no config entry / subentry pairs
+ dropped_device_ids.add(device["id"])
+ continue
+ if len(pairs) == 1:
+ config_entry_id, subentry_id = pairs[0]
+ device["config_entry_id"] = config_entry_id
+ device["config_subentry_id"] = subentry_id
+ device["composite_device_id"] = None
+ device["composite_primary_config_entry"] = None
+ device["split_at"] = None
+ device["has_composite_identifiers"] = False
+ devices.append(device)
+ continue
+ old_id = device["id"]
+ composite_primary = device.get("primary_config_entry")
+ for config_entry_id, subentry_id in pairs:
+ split = copy.deepcopy(device)
+ split["id"] = uuid_util.random_uuid_hex()
+ split["config_entry_id"] = config_entry_id
+ split["config_subentry_id"] = subentry_id
+ split["primary_config_entry"] = config_entry_id
+ split["composite_device_id"] = old_id
+ split["composite_primary_config_entry"] = composite_primary
+ split["split_at"] = migrated_at
+ split["has_composite_identifiers"] = True
+ devices.append(split)
+ migrated_active_splits.append(split)
+ composite_splits.setdefault(old_id, {})[config_entry_id] = split[
+ "id"
+ ]
+ # Rewrite via_device_id links that pointed at a now-split composite parent
+ # to a live split: the parent's split in the child's own config entry when
+ # there is one, otherwise any of the parent's splits, so the link never
+ # dangles on the removed composite id. A link to a retained unsplit parent is
+ # left unchanged; a link to a dropped parent is detached below.
+ for device in devices:
+ if (
+ splits := composite_splits.get(device["via_device_id"])
+ ) is not None:
+ device["via_device_id"] = splits.get(
+ device["config_entry_id"], next(iter(splits.values()))
+ )
+ elif device["via_device_id"] in dropped_device_ids:
+ # The parent was dropped (no config entries); detach the link as
+ # async_remove_device would, so it does not dangle on a removed id.
+ device["via_device_id"] = None
+ old_data["devices"] = devices
+ # A split inherited the composite's disabled_by, which may not match its
+ # single config entry (e.g. a split owned by an enabled entry must not stay
+ # CONFIG_ENTRY disabled). Config entries load concurrently, so wait for them
+ # and reconcile each split against its own entry.
+ if migrated_active_splits:
+ await self.hass.config_entries.async_wait_initialized()
+ for split in migrated_active_splits:
+ config_entry = self.hass.config_entries.async_get_entry(
+ split["config_entry_id"]
+ )
+ if config_entry is not None:
+ _migrate_device_disabled_by(
+ split, config_entry.disabled_by is not None
+ )
+ deleted_devices: list[dict[str, Any]] = []
+ for device in old_data["deleted_devices"]:
+ # One target per config entry. config_entries_subentries was a set, so
+ # the old model allowed a device in several subentries of one config
+ # entry, but the single-owner model keeps one. Multi-subentry devices
+ # created by core integrations all come from broken subentry migrators
+ # (which left a device in both None and its real subentry), so prefer
+ # a real subentry over the main entry (None). Collapsing rather than
+ # splitting avoids duplicate devices which, sharing identifiers and
+ # connections within one config entry, would collide in the
+ # per-config-entry identifier/connection index.
+ pairs = [
+ (
+ config_entry_id,
+ next((s for s in subentry_ids if s is not None), None),
+ )
+ for config_entry_id, subentry_ids in device[
+ "config_entries_subentries"
+ ].items()
+ ]
+ if len(pairs) <= 1:
+ # Unlike active devices, config_entry_id=None is a valid
+ # (orphaned) state for a deleted device, so a deleted device with
+ # no config entries is kept rather than dropped.
+ config_entry_id, subentry_id = pairs[0] if pairs else (None, None)
+ device["config_entry_id"] = config_entry_id
+ device["config_subentry_id"] = subentry_id
+ device["domain"] = None
+ deleted_devices.append(device)
+ continue
+ # A deleted device that belonged to several config entries or subentries
+ # is split like an active one - each split keeps a copy of the
+ # identifiers/connections so every config entry can still restore its
+ # share when a matching device is re-registered.
+ for config_entry_id, subentry_id in pairs:
+ split = copy.deepcopy(device)
+ split["id"] = uuid_util.random_uuid_hex()
+ split["config_entry_id"] = config_entry_id
+ split["config_subentry_id"] = subentry_id
+ split["domain"] = None
+ deleted_devices.append(split)
+ old_data["deleted_devices"] = deleted_devices
+ # config_entries and config_entries_subentries are deprecated; v3 stores only
+ # the singular config_entry_id / config_subentry_id (single-entry devices kept
+ # the old keys, splits copied them via deepcopy).
+ for migrated in (*devices, *deleted_devices):
+ migrated.pop("config_entries", None)
+ migrated.pop("config_entries_subentries", None)
- if old_major_version > 2:
+ if old_major_version > 3:
raise NotImplementedError
return old_data
+ async def _async_backup_store(self) -> None:
+ """Copy the store file to a timestamped backup before migrating."""
+ source = self.path
+ backup = f"{source}.{utcnow().strftime('%Y%m%d_%H%M%S')}.migration_backup"
+ try:
+ copied = await self.hass.async_add_executor_job(
+ _copy_if_exists, source, backup
+ )
+ except OSError as err:
+ _LOGGER.warning("Could not back up %s before migration: %s", source, err)
+ else:
+ if copied:
+ _LOGGER.info("Backed up %s to %s before migration", source, backup)
+
class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)](
BaseRegistryItems[_EntryTypeT]
):
"""Container for device registry items, maps device id -> entry.
- Maintains two additional indexes:
- - (connection_type, connection identifier) -> entry
- - (DOMAIN, identifier) -> entry
+ Maintains two additional indexes. An identifier or connection can be shared by
+ several devices, each belonging to a different config entry, so each index maps a
+ connection or identifier to the devices that have it, keyed by config entry id:
+ - (connection_type, connection identifier) -> {config_entry_id: entry}
+ - (DOMAIN, identifier) -> {config_entry_id: entry}
"""
def __init__(self) -> None:
"""Initialize the container."""
super().__init__()
- self._connections: dict[tuple[str, str], _EntryTypeT] = {}
- self._identifiers: dict[tuple[str, str], _EntryTypeT] = {}
+ self._connections: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {}
+ self._identifiers: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {}
@override
def _index_entry(self, key: str, entry: _EntryTypeT) -> None:
"""Index an entry."""
+ config_entry_id = entry.config_entry_id
for connection in entry.connections:
- self._connections[connection] = entry
+ self._connections.setdefault(connection, {})[config_entry_id] = entry
for identifier in entry.identifiers:
- self._identifiers[identifier] = entry
+ self._identifiers.setdefault(identifier, {})[config_entry_id] = entry
@override
def _unindex_entry(
self, key: str, replacement_entry: _EntryTypeT | None = None
) -> None:
- """Unindex an entry."""
+ """Unindex an entry.
+
+ Guards against collisions, the code below can be simplified once
+ collisions are not longer allowed, refer to commit history in PR
+ 175785.
+ """
old_entry = self.data[key]
+ config_entry_id = old_entry.config_entry_id
for connection in old_entry.connections:
- if connection in self._connections:
- del self._connections[connection]
+ by_config_entry = self._connections.get(connection)
+ if by_config_entry is not None and (
+ by_config_entry.get(config_entry_id) is old_entry
+ ):
+ del by_config_entry[config_entry_id]
+ if not by_config_entry:
+ del self._connections[connection]
for identifier in old_entry.identifiers:
- if identifier in self._identifiers:
- del self._identifiers[identifier]
+ by_config_entry = self._identifiers.get(identifier)
+ if by_config_entry is not None and (
+ by_config_entry.get(config_entry_id) is old_entry
+ ):
+ del by_config_entry[config_entry_id]
+ if not by_config_entry:
+ del self._identifiers[identifier]
def get_entry(
self,
identifiers: set[tuple[str, str]] | None = None,
connections: set[tuple[str, str]] | None = None,
+ *,
+ config_entry_id: str | None | UndefinedType = UNDEFINED,
) -> _EntryTypeT | None:
- """Get entry from identifiers or connections."""
+ """Get the first entry matching identifiers or connections.
+
+ If config_entry_id is given, only an entry belonging to that config entry is
+ returned. Otherwise the first matching entry from any config entry is returned.
+ """
if identifiers:
for identifier in identifiers:
- if identifier in self._identifiers:
- return self._identifiers[identifier]
+ if (by_config_entry := self._identifiers.get(identifier)) is not None:
+ if config_entry_id is UNDEFINED:
+ return next(iter(by_config_entry.values()))
+ if config_entry_id in by_config_entry:
+ return by_config_entry[config_entry_id]
if not connections:
return None
for connection in _normalize_connections(connections):
- if connection in self._connections:
- return self._connections[connection]
+ if (by_config_entry := self._connections.get(connection)) is not None:
+ if config_entry_id is UNDEFINED:
+ return next(iter(by_config_entry.values()))
+ if config_entry_id in by_config_entry:
+ return by_config_entry[config_entry_id]
return None
def get_entries(
self,
- identifiers: set[tuple[str, str]] | None,
- connections: set[tuple[str, str]] | None,
- ) -> Iterable[_EntryTypeT]:
- """Get entries from identifiers or connections."""
+ identifiers: AbstractSet[tuple[str, str]] | None = None,
+ connections: AbstractSet[tuple[str, str]] | None = None,
+ ) -> list[_EntryTypeT]:
+ """Get all entries matching identifiers or connections, across config entries."""
+ entries: dict[str, _EntryTypeT] = {}
if identifiers:
for identifier in identifiers:
- if identifier in self._identifiers:
- yield self._identifiers[identifier]
+ if (by_config_entry := self._identifiers.get(identifier)) is not None:
+ for entry in by_config_entry.values():
+ entries[entry.id] = entry
if connections:
for connection in _normalize_connections(connections):
- if connection in self._connections:
- yield self._connections[connection]
+ if (by_config_entry := self._connections.get(connection)) is not None:
+ for entry in by_config_entry.values():
+ entries[entry.id] = entry
+ return list(entries.values())
class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]):
@@ -759,16 +1105,18 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]):
def __init__(self) -> None:
"""Initialize the container.
- Maintains three additional indexes:
+ Maintains four additional indexes:
- area_id -> dict[key, True]
- config_entry_id -> dict[key, True]
- label -> dict[key, True]
+ - composite_device_id -> dict[key, True]
"""
super().__init__()
self._area_id_index: RegistryIndexType = defaultdict(dict)
self._config_entry_id_index: RegistryIndexType = defaultdict(dict)
self._labels_index: RegistryIndexType = defaultdict(dict)
+ self._composite_device_id_index: RegistryIndexType = defaultdict(dict)
@override
def _index_entry(self, key: str, entry: DeviceEntry) -> None:
@@ -778,8 +1126,9 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]):
self._area_id_index[area_id][key] = True
for label in entry.labels:
self._labels_index[label][key] = True
- for config_entry_id in entry.config_entries:
- self._config_entry_id_index[config_entry_id][key] = True
+ self._config_entry_id_index[entry.config_entry_id][key] = True
+ if entry.composite_device_id is not None:
+ self._composite_device_id_index[entry.composite_device_id][key] = True
@override
def _unindex_entry(
@@ -792,8 +1141,13 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]):
if labels := entry.labels:
for label in labels:
self._unindex_entry_value(key, label, self._labels_index)
- for config_entry_id in entry.config_entries:
- self._unindex_entry_value(key, config_entry_id, self._config_entry_id_index)
+ self._unindex_entry_value(
+ key, entry.config_entry_id, self._config_entry_id_index
+ )
+ if entry.composite_device_id is not None:
+ self._unindex_entry_value(
+ key, entry.composite_device_id, self._composite_device_id_index
+ )
super()._unindex_entry(key, replacement_entry)
def get_devices_for_area_id(self, area_id: str) -> list[DeviceEntry]:
@@ -815,12 +1169,98 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]):
data[key] for key in self._config_entry_id_index.get(config_entry_id, ())
]
+ def get_devices_for_composite_device_id(
+ self, composite_device_id: str
+ ) -> list[DeviceEntry]:
+ """Get the devices a pre-migration composite device was split into."""
+ data = self.data
+ return [
+ data[key]
+ for key in self._composite_device_id_index.get(composite_device_id, ())
+ ]
+
+
+class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]):
+ """Container for deleted device registry entries.
+
+ A deleted device that still belongs to a config entry is indexed by config entry id in
+ the base class, like an active device. An orphaned deleted device (its config entry
+ removed) has no config entry id and would collide with every other orphan in the base
+ config_entry_id=None slot, so orphans are kept out of the base index and tracked in a
+ separate index keyed by device id, which is unique so orphans never shadow each other.
+ Orphans are matched on restore by get_orphaned_entry.
+ """
+
+ def __init__(self) -> None:
+ """Initialize the container."""
+ super().__init__()
+ self._orphaned_connections: dict[
+ tuple[str, str], dict[str, DeletedDeviceEntry]
+ ] = {}
+ self._orphaned_identifiers: dict[
+ tuple[str, str], dict[str, DeletedDeviceEntry]
+ ] = {}
+
+ @override
+ def _index_entry(self, key: str, entry: DeletedDeviceEntry) -> None:
+ """Index an entry, keeping orphans in the separate id-keyed index."""
+ if entry.config_entry_id is not None:
+ super()._index_entry(key, entry)
+ return
+ for connection in entry.connections:
+ self._orphaned_connections.setdefault(connection, {})[entry.id] = entry
+ for identifier in entry.identifiers:
+ self._orphaned_identifiers.setdefault(identifier, {})[entry.id] = entry
+
+ @override
+ def _unindex_entry(
+ self, key: str, replacement_entry: DeletedDeviceEntry | None = None
+ ) -> None:
+ """Unindex an entry from the base or the orphan index."""
+ entry = self.data[key]
+ if entry.config_entry_id is not None:
+ super()._unindex_entry(key, replacement_entry)
+ return
+ for connection in entry.connections:
+ if connection in self._orphaned_connections:
+ del self._orphaned_connections[connection][entry.id]
+ if not self._orphaned_connections[connection]:
+ del self._orphaned_connections[connection]
+ for identifier in entry.identifiers:
+ if identifier in self._orphaned_identifiers:
+ del self._orphaned_identifiers[identifier][entry.id]
+ if not self._orphaned_identifiers[identifier]:
+ del self._orphaned_identifiers[identifier]
+
+ def get_orphaned_entry(
+ self,
+ identifiers: set[tuple[str, str]] | None,
+ connections: set[tuple[str, str]] | None,
+ domain: str,
+ ) -> DeletedDeviceEntry | None:
+ """Return an orphan of the given domain to restore.
+
+ Orphans are matched on their recorded domain so a chance identifier or connection
+ collision doesn't restore another integration's device. A domain-less orphan
+ (carried over by the migration with no recoverable domain) is left for the
+ periodic purge rather than restored.
+ """
+ orphans: dict[str, DeletedDeviceEntry] = {}
+ for identifier in identifiers or ():
+ orphans.update(self._orphaned_identifiers.get(identifier, {}))
+ for connection in _normalize_connections(connections or set()):
+ orphans.update(self._orphaned_connections.get(connection, {}))
+ for entry in orphans.values():
+ if entry.domain == domain:
+ return entry
+ return None
+
class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
"""Class to hold a registry of devices."""
devices: ActiveDeviceRegistryItems
- deleted_devices: DeviceRegistryItems[DeletedDeviceEntry]
+ deleted_devices: DeletedDeviceRegistryItems
_device_data: dict[str, DeviceEntry]
def __init__(self, hass: HomeAssistant) -> None:
@@ -842,8 +1282,53 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
We retrieve the DeviceEntry from the underlying dict to avoid
the overhead of the UserDict __getitem__.
+
+ For a pre-migration composite device id, a read-only composite device
+ merged from the split devices is returned, so integration code that resolves a
+ device by id (e.g. in a service handler) keeps working. The composite is
+ synthesized on demand and never stored, so it stays invisible to enumeration,
+ identifier search and the frontend device list.
"""
- return self._device_data.get(device_id)
+ if (device := self._device_data.get(device_id)) is not None:
+ return device
+ if split_devices := self.devices.get_devices_for_composite_device_id(device_id):
+ return self._restore_composite_device(device_id, split_devices)
+ return None
+
+ @callback
+ def _restore_composite_device(
+ self, device_id: str, split_devices: list[DeviceEntry]
+ ) -> DeviceEntry:
+ """Synthesize a read-only composite device from its split devices."""
+ composite_subentries: dict[str, set[str | None]] = {}
+ identifiers: set[tuple[str, str]] = set()
+ connections: set[tuple[str, str]] = set()
+ for split_device in split_devices:
+ composite_subentries.setdefault(split_device.config_entry_id, set()).add(
+ split_device.config_subentry_id
+ )
+ identifiers |= split_device.identifiers
+ connections |= split_device.connections
+ # Functional identity (identifiers, connections, serial_number) is consistent
+ # across splits of the same physical device. Use the split owning the composite's
+ # former primary config entry as the base, so config_entry_id - and thus
+ # primary_config_entry - reports the composite's former primary.
+ primary_config_entry = split_devices[0].composite_primary_config_entry
+ base = next(
+ (
+ split_device
+ for split_device in split_devices
+ if split_device.config_entry_id == primary_config_entry
+ ),
+ split_devices[0],
+ )
+ return attr.evolve(
+ base,
+ composite_subentries=composite_subentries,
+ connections=connections, # type: ignore[arg-type]
+ id=device_id,
+ identifiers=identifiers, # type: ignore[arg-type]
+ )
@callback
def async_get_device(
@@ -851,8 +1336,100 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
identifiers: set[tuple[str, str]] | None = None,
connections: set[tuple[str, str]] | None = None,
) -> DeviceEntry | None:
- """Check if device is registered."""
- return self.devices.get_entry(identifiers, connections)
+ """Check if a device is registered.
+
+ Identifiers and connections are unique per config entry. If several config
+ entries share the looked-up identifier or connection, the match is resolved to a
+ single device when possible - preferring the device whose config entry domain
+ matches the looked-up identifier. If the remaining matches are the splits of one
+ pre-migration composite device, a read-only composite spanning them is returned
+ (async_update_device and async_remove_device fan it out to the underlying
+ devices). Otherwise, for independent devices sharing an identifier or connection,
+ one owned by the calling integration is preferred, falling back to the first
+ match.
+ """
+ matches = self._async_matching_devices(identifiers, connections)
+ if len(matches) <= 1:
+ return matches[0] if matches else None
+ # If the matches are the splits of one pre-migration composite device, return a
+ # read-only composite over them, reusing the composite's id so stored references
+ # (an automation, a fired event, or an entity holding the old device id) keep
+ # resolving to it as before the split.
+ composite_device_ids = {match.composite_device_id for match in matches}
+ if (
+ len(composite_device_ids) == 1
+ and (pre_migration_id := next(iter(composite_device_ids))) is not None
+ ):
+ return self._restore_composite_device(pre_migration_id, matches)
+ # Otherwise they are independent devices sharing an identifier or connection.
+ # Prefer one owned by the calling integration so the caller resolves to its own
+ # device rather than an insertion-order-dependent one; fall back to the first.
+ if (domain := _current_integration_domain()) is not None and (
+ device := self._first_device_in_domain(matches, domain)
+ ) is not None:
+ return device
+ return matches[0]
+
+ def _first_device_in_domain(
+ self, devices: Iterable[DeviceEntry], domain: str
+ ) -> DeviceEntry | None:
+ """Return the first device whose config entry belongs to domain."""
+ for device in devices:
+ entry = self.hass.config_entries.async_get_entry(device.config_entry_id)
+ if entry is not None and entry.domain == domain:
+ return device
+ return None
+
+ @callback
+ def _async_matching_devices(
+ self,
+ identifiers: AbstractSet[tuple[str, str]] | None,
+ connections: AbstractSet[tuple[str, str]] | None,
+ ) -> list[DeviceEntry]:
+ """Return devices matching the lookup, narrowed by identifier-domain priority."""
+ matches = self.devices.get_entries(identifiers, connections)
+ if len(matches) > 1 and identifiers:
+ domains = {identifier[0] for identifier in identifiers}
+ preferred = [
+ device
+ for device in matches
+ if (
+ entry := self.hass.config_entries.async_get_entry(
+ device.config_entry_id
+ )
+ )
+ and entry.domain in domains
+ ]
+ if preferred:
+ return preferred
+ return matches
+
+ @callback
+ def _async_device_ids_for_composite_device_id(
+ self, device_id: str
+ ) -> list[str] | None:
+ """Return the underlying real device ids if device_id is a composite."""
+ if device_id in self.devices:
+ return None
+ if split_devices := self.devices.get_devices_for_composite_device_id(device_id):
+ return [split_device.id for split_device in split_devices]
+ return None
+
+ @callback
+ def async_get_devices_for_composite_device_id(
+ self, composite_device_id: str
+ ) -> list[DeviceEntry]:
+ """Return the devices a composite device id represents.
+
+ A composite device id is a pre-migration composite id - a device that belonged to
+ several config entries, split into one device per config entry, each keeping the
+ original id as composite_device_id. The underlying live devices are returned so
+ that actions and entity lookups targeting the composite id still reach all of
+ them; unmodified integrations keep the pre-rewrite behaviour, where a shared
+ identifier/connection resolved to a single multi-config-entry device. Returns an
+ empty list for a device id which is not a composite device id.
+ """
+ return self.devices.get_devices_for_composite_device_id(composite_device_id)
def _substitute_name_placeholders(
self,
@@ -908,7 +1485,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
sw_version: str | None | UndefinedType = UNDEFINED,
translation_key: str | None = None,
translation_placeholders: Mapping[str, str] | None = None,
+ # via_device is deprecated and will be removed in HA Core 2027.8, use
+ # via_device_id instead
via_device: tuple[str, str] | None | UndefinedType = UNDEFINED,
+ via_device_id: str | None | UndefinedType = UNDEFINED,
) -> DeviceEntry:
"""Get device. Create if it doesn't exist."""
default_manufacturer = _validate_str(
@@ -931,6 +1511,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
f"Can't link device to unknown config entry {config_entry_id}"
)
+ # Validate before mutating the registry below. `via_device=None` (an explicit
+ # "no via device") alongside a via_device_id is contradictory, so reject it too.
+ if via_device is not UNDEFINED and via_device_id is not UNDEFINED:
+ raise HomeAssistantError(
+ "Passing both `via_device` and `via_device_id` is not allowed; "
+ "`via_device` is deprecated, pass `via_device_id` only"
+ )
+ if (
+ config_subentry_id is not UNDEFINED
+ and config_subentry_id is not None
+ and config_subentry_id not in config_entry.subentries
+ ):
+ raise HomeAssistantError(
+ f"Config entry {config_entry_id} has no subentry {config_subentry_id}"
+ )
+
if translation_key:
full_translation_key = (
f"component.{config_entry.domain}.device.{translation_key}.name"
@@ -958,6 +1554,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
("name", name),
("suggested_area", suggested_area),
("via_device", via_device),
+ ("via_device_id", via_device_id),
*validated_fields.items(),
)
if val is not UNDEFINED
@@ -974,7 +1571,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
connections = _normalize_connections(connections)
device = self.devices.get_entry(
- identifiers=identifiers, connections=connections
+ connections=connections,
+ identifiers=identifiers,
+ config_entry_id=config_entry_id,
)
is_new = False
@@ -982,7 +1581,20 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if device is None:
is_new = True
- deleted_device = self.deleted_devices.get_entry(identifiers, connections)
+ deleted_device = self.deleted_devices.get_entry(
+ connections=connections,
+ identifiers=identifiers,
+ config_entry_id=config_entry_id,
+ )
+ if deleted_device is None:
+ # Fall back to an orphan (its owning config entry was removed)
+ # so re-adding an integration restores the device id, area, labels and name
+ # rather than create a fresh device. Matching on the recorded domain keeps
+ # a chance identifier/connection collision from restoring another
+ # integration's device.
+ deleted_device = self.deleted_devices.get_orphaned_entry(
+ identifiers, connections, config_entry.domain
+ )
if deleted_device is None:
area_id: str | None = None
if (
@@ -995,7 +1607,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
area = ar.async_get(self.hass).async_get_or_create(suggested_area)
area_id = area.id
- device = DeviceEntry(area_id=area_id)
+ device = DeviceEntry(
+ area_id=area_id,
+ config_entry_id=config_entry_id,
+ # Interpret not specifying a subentry as None
+ config_subentry_id=(
+ config_subentry_id
+ if config_subentry_id is not UNDEFINED
+ else None
+ ),
+ )
else:
self.deleted_devices.pop(deleted_device.id)
@@ -1024,7 +1645,22 @@ 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.devices.get_entry(identifiers={via_device})) is None:
+ # Resolve the deprecated via_device to a device id. The identifier is not
+ # unique across config entries, so prefer a via device in the same config
+ # entry, then one from the same integration (domain), falling back to any
+ # config entry (a via device may legitimately belong to a different config
+ # entry). This ambiguity is why via_device is deprecated.
+ via = (
+ self.devices.get_entry(
+ identifiers={via_device}, config_entry_id=config_entry_id
+ )
+ or self._first_device_in_domain(
+ self.devices.get_entries(identifiers={via_device}),
+ config_entry.domain,
+ )
+ or self.devices.get_entry(identifiers={via_device})
+ )
+ if via is None:
report_usage(
"calls `device_registry.async_get_or_create` referencing a "
f"non existing `via_device` {via_device}, "
@@ -1032,25 +1668,46 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
core_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2025.12.0",
)
+ via_device_id = via.id if via else UNDEFINED
+ elif via_device is None:
+ # An explicit `via_device=None` means "no via device" (a via_device_id
+ # alongside it is rejected above).
+ via_device_id = None
- via_device_id: str | UndefinedType = via.id if via else UNDEFINED
+ # On the owning integration's first re-registration of a device created by
+ # splitting a pre-migration composite device, replace the identifiers and
+ # connections copied from the composite with the ones the integration provides,
+ # instead of merging. This block and the has_composite_identifiers flag
+ # can be removed in HA Core 2027.8.
+ identifiers_connections: dict[str, Any]
+ has_composite_identifiers: bool | UndefinedType = UNDEFINED
+ if not is_new and device.has_composite_identifiers:
+ identifiers_connections = {
+ "new_connections": connections,
+ "new_identifiers": identifiers,
+ }
+ has_composite_identifiers = False
else:
- via_device_id = UNDEFINED
+ identifiers_connections = {
+ "merge_connections": connections or UNDEFINED,
+ "merge_identifiers": identifiers or UNDEFINED,
+ }
device = self._async_update_device(
device.id,
allow_collisions=True,
- add_config_entry_id=config_entry_id,
- add_config_subentry_id=config_subentry_id,
- device_info_type=device_info_type,
disabled_by=disabled_by,
entry_type=entry_type,
is_new=is_new,
- merge_connections=connections or UNDEFINED,
- merge_identifiers=identifiers or UNDEFINED,
name=name,
+ has_composite_identifiers=has_composite_identifiers,
+ # Move the device if the integration re-registers it under a different
+ # subentry; UNDEFINED leaves the subentry unchanged. Also validates an
+ # explicitly provided subentry for new devices.
+ new_config_subentry_id=config_subentry_id,
suggested_area=suggested_area,
via_device_id=via_device_id,
+ **identifiers_connections,
**validated_fields,
)
@@ -1071,7 +1728,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
allow_collisions: bool = False,
area_id: str | None | UndefinedType = UNDEFINED,
configuration_url: str | URL | None | UndefinedType = UNDEFINED,
- device_info_type: str | UndefinedType = UNDEFINED,
disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED,
entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED,
hw_version: str | None | UndefinedType = UNDEFINED,
@@ -1084,6 +1740,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
model_id: str | None | UndefinedType = UNDEFINED,
name_by_user: str | None | UndefinedType = UNDEFINED,
name: str | None | UndefinedType = UNDEFINED,
+ # has_composite_identifiers can be removed in HA Core 2027.8
+ has_composite_identifiers: bool | UndefinedType = UNDEFINED,
+ new_config_entry_id: str | UndefinedType = UNDEFINED,
+ new_config_subentry_id: str | None | UndefinedType = UNDEFINED,
new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
remove_config_entry_id: str | UndefinedType = UNDEFINED,
@@ -1106,9 +1766,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
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 (
add_config_entry := self.hass.config_entries.async_get_entry(
@@ -1143,6 +1800,26 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
"Can't remove config subentry without specifying config entry"
)
+ if (
+ new_config_entry_id is not UNDEFINED
+ and self.hass.config_entries.async_get_entry(new_config_entry_id) is None
+ ):
+ raise HomeAssistantError(
+ f"Can't move device to unknown config entry {new_config_entry_id}"
+ )
+
+ if (
+ new_config_entry_id is not UNDEFINED
+ or new_config_subentry_id is not UNDEFINED
+ ) and (
+ add_config_entry_id is not UNDEFINED
+ or remove_config_entry_id is not UNDEFINED
+ ):
+ raise HomeAssistantError(
+ "Can't combine new_config_entry_id or new_config_subentry_id with "
+ "add_config_entry_id or remove_config_entry_id"
+ )
+
if not new_connections and not new_identifiers:
raise HomeAssistantError(
"A device must have at least one of identifiers or connections"
@@ -1158,109 +1835,133 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
"Cannot define both merge_identifiers and new_identifiers"
)
- 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"
- and add_config_entry_id != primary_entry_id
- ):
- if (
- primary_entry_id is None
- or not (
- primary_entry := self.hass.config_entries.async_get_entry(
- primary_entry_id
- )
+ # A device belongs to exactly one config entry and subentry:
+ # - add_config_entry_id (with an optional add_config_subentry_id) records a
+ # transient pending move to that config entry and subentry; on its own it does
+ # not move the device. Integrations move a device by adding the new config
+ # entry and then removing the current one, often in separate calls; the removal
+ # of the current config entry performs the pending move.
+ # - remove_config_entry_id on the owning entry performs a pending move if there
+ # is one, otherwise it removes the device, since it has no other config entry.
+ # - new_config_entry_id / new_config_subentry_id move the device immediately.
+ target_config_entry_id: str | UndefinedType = UNDEFINED
+ target_config_subentry_id: str | None | UndefinedType = UNDEFINED
+ pending_move: _PendingMove | None | UndefinedType = UNDEFINED
+ if new_config_entry_id is not UNDEFINED:
+ target_config_entry_id = new_config_entry_id
+ target_config_subentry_id = (
+ new_config_subentry_id
+ if new_config_subentry_id is not UNDEFINED
+ else None
+ )
+ # An immediate move to a new config entry supersedes a deferred move from an
+ # earlier add_config_entry_id; clear it so a later removal of the new owner
+ # deletes the device instead of performing the stale move.
+ pending_move = None
+ elif new_config_subentry_id is not UNDEFINED:
+ target_config_subentry_id = new_config_subentry_id
+ else:
+ if add_config_entry_id is not UNDEFINED:
+ # Adding the config entry (and subentry) the device already belongs to is a
+ # no-op; recording it as a pending move would make a later removal of that
+ # sole owner move the device to itself instead of deleting it.
+ already_owner = add_config_entry_id == old.config_entry_id and (
+ add_config_subentry_id is UNDEFINED
+ or add_config_subentry_id == old.config_subentry_id
+ )
+ if not already_owner:
+ pending_move = _PendingMove(
+ add_config_entry_id,
+ add_config_subentry_id
+ if add_config_subentry_id is not UNDEFINED
+ else None,
+ _current_integration_domain(),
)
- or primary_entry.domain in LOW_PRIO_CONFIG_ENTRY_DOMAINS
- ):
- new_values["primary_config_entry"] = add_config_entry_id
- old_values["primary_config_entry"] = primary_entry_id
-
- 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}
- }
- # Enable the device if it was disabled by config entry and we're adding
- # a non disabled config entry
+ if remove_config_entry_id == old.config_entry_id and (
+ remove_config_subentry_id is UNDEFINED
+ or remove_config_subentry_id == old.config_subentry_id
+ ):
+ move_from_prior_call = pending_move is UNDEFINED
+ move_target = (
+ pending_move if pending_move is not UNDEFINED else old._pending_move # noqa: SLF001
+ )
+ # A deferred move armed by an earlier add_config_entry_id only completes
+ # if the integration now removing the owning entry is the one that armed
+ # it. A removal from a different integration (e.g. device_tracker
+ # attaching a shared MAC) is unrelated, so cancel the move and delete the
+ # device instead of silently transferring it. Origins from core/tests are
+ # undetermined (None) and never cancel.
if (
- # mypy says add_config_entry can be None.
- # That's impossible, because we raise above if
- # that happens
- not add_config_entry.disabled_by # type: ignore[union-attr]
- and old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY
+ move_target is not None
+ and move_from_prior_call
+ and move_target.origin_domain is not None
+ and (current_domain := _current_integration_domain()) is not None
+ and current_domain != move_target.origin_domain
):
- new_values["disabled_by"] = None
- old_values["disabled_by"] = old.disabled_by
- 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 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 not in config_entries_subentries:
- if config_entries == {remove_config_entry_id}:
+ move_target = None
+ if move_target is None:
self.async_remove_device(device_id)
return None
+ target_config_entry_id = move_target.config_entry_id
+ target_config_subentry_id = move_target.config_subentry_id
+ pending_move = None
+ # A pre-migration composite's splits share identity, so once one split
+ # completes the move to the target entry the others must not also move
+ # there and collide; clear their pending moves.
+ if old.composite_device_id is not None:
+ for sibling in self.devices.get_devices_for_composite_device_id(
+ old.composite_device_id
+ ):
+ if (
+ sibling.id != device_id
+ and sibling._pending_move is not None # noqa: SLF001
+ ):
+ self.devices[sibling.id] = attr.evolve(
+ sibling, pending_move=None
+ )
- 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}
-
- # Disable the device if it is enabled and all remaining config entries
- # are disabled
- has_enabled_config_entries = any(
- config_entry.disabled_by is None
- for config_entry_id in config_entries
- if (
- config_entry := self.hass.config_entries.async_get_entry(
- config_entry_id
- )
- )
- is not None
+ if target_config_subentry_id not in (UNDEFINED, None):
+ resolved_config_entry_id = (
+ target_config_entry_id
+ if target_config_entry_id is not UNDEFINED
+ else old.config_entry_id
+ )
+ resolved_config_entry = self.hass.config_entries.async_get_entry(
+ resolved_config_entry_id
+ )
+ if (
+ resolved_config_entry is None
+ or target_config_subentry_id not in resolved_config_entry.subentries
+ ):
+ raise HomeAssistantError(
+ f"Config entry {resolved_config_entry_id} has no"
+ f" subentry {target_config_subentry_id}"
)
- if not has_enabled_config_entries and old.disabled_by is None:
- new_values["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY
- old_values["disabled_by"] = old.disabled_by
- if config_entries != old.config_entries:
- new_values["config_entries"] = config_entries
- old_values["config_entries"] = old.config_entries
+ if (
+ target_config_entry_id is not UNDEFINED
+ and target_config_entry_id != old.config_entry_id
+ ):
+ new_values["config_entry_id"] = target_config_entry_id
+ old_values["config_entry_id"] = old.config_entry_id
+ if (
+ target_config_subentry_id is not UNDEFINED
+ and target_config_subentry_id != old.config_subentry_id
+ ):
+ new_values["config_subentry_id"] = target_config_subentry_id
+ old_values["config_subentry_id"] = old.config_subentry_id
+ # pending_move is a transient runtime-only attribute; it is not reported in the
+ # update event (not added to old_values) and never stored
+ if pending_move is not UNDEFINED and pending_move != old._pending_move: # noqa: SLF001
+ new_values["pending_move"] = pending_move
- 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
+ # Identifiers and connections are unique per config entry, so when the device is
+ # moved to another config entry they are validated against the new one
+ effective_config_entry_id = (
+ target_config_entry_id
+ if target_config_entry_id is not UNDEFINED
+ else old.config_entry_id
+ )
added_connections: set[tuple[str, str]] | None = None
added_identifiers: set[tuple[str, str]] | None = None
@@ -1268,6 +1969,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if merge_connections is not UNDEFINED:
normalized_connections = self._validate_connections(
device_id,
+ effective_config_entry_id,
merge_connections,
allow_collisions,
)
@@ -1279,7 +1981,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if merge_identifiers is not UNDEFINED:
merge_identifiers = self._validate_identifiers(
- device_id, merge_identifiers, allow_collisions
+ device_id,
+ effective_config_entry_id,
+ merge_identifiers,
+ allow_collisions,
)
old_identifiers = old.identifiers
if not merge_identifiers.issubset(old_identifiers):
@@ -1289,16 +1994,52 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if new_connections is not UNDEFINED:
added_connections = new_values["connections"] = self._validate_connections(
- device_id, new_connections, False
+ device_id, effective_config_entry_id, new_connections, False
)
old_values["connections"] = old.connections
if new_identifiers is not UNDEFINED:
added_identifiers = new_values["identifiers"] = self._validate_identifiers(
- device_id, new_identifiers, False
+ device_id, effective_config_entry_id, new_identifiers, False
)
old_values["identifiers"] = old.identifiers
+ # On a move to another config entry, validate the identifiers and connections
+ # retained from the old entry against the new one, so the move can't silently
+ # overwrite the index slot of a device that already has the same identity there.
+ # A full new_identifiers / new_connections replacement is validated above;
+ # merge_* only adds, so the retained old values still need checking here.
+ if effective_config_entry_id != old.config_entry_id:
+ if new_identifiers is UNDEFINED:
+ self._validate_identifiers(
+ device_id, effective_config_entry_id, old.identifiers, False
+ )
+ if new_connections is UNDEFINED:
+ self._validate_connections(
+ device_id, effective_config_entry_id, old.connections, False
+ )
+
+ # On a move, reflect the new owning config entry's disabled state (as restoring a
+ # deleted device does) unless disabled_by was passed explicitly: disable an
+ # enabled device moved onto a disabled entry, and clear a CONFIG_ENTRY disable
+ # when moved onto an enabled entry. A USER disable is preserved.
+ if (
+ disabled_by is UNDEFINED
+ and target_config_entry_id is not UNDEFINED
+ and target_config_entry_id != old.config_entry_id
+ and (
+ target_entry := self.hass.config_entries.async_get_entry(
+ target_config_entry_id
+ )
+ )
+ is not None
+ ):
+ if target_entry.disabled_by:
+ if old.disabled_by is None:
+ disabled_by = DeviceEntryDisabler.CONFIG_ENTRY
+ elif old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY:
+ disabled_by = None
+
for attr_name, value in (
("area_id", area_id),
("configuration_url", configuration_url),
@@ -1311,6 +2052,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
("model_id", model_id),
("name", name),
("name_by_user", name_by_user),
+ ("has_composite_identifiers", has_composite_identifiers),
("serial_number", serial_number),
("sw_version", sw_version),
("via_device_id", via_device_id),
@@ -1336,13 +2078,28 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
new = attr.evolve(old, **new_values)
self.devices[device_id] = new
- # 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.
+ # On a move, the device's whole retained identity newly appears in the target
+ # config entry; added_identifiers/added_connections are empty on a retained-
+ # identity move, so match the target entry's deleted device by the full identity.
+ match_identifiers: set[tuple[str, str]] | None
+ match_connections: set[tuple[str, str]] | None
+ if effective_config_entry_id != old.config_entry_id:
+ match_identifiers = new.identifiers
+ match_connections = new.connections
+ else:
+ match_identifiers = added_identifiers
+ match_connections = added_connections
for deleted_device in self.deleted_devices.get_entries(
- added_identifiers, added_connections
+ match_identifiers, match_connections
):
- del self.deleted_devices[deleted_device.id]
+ # get_entries matches across config entries, but identifiers/connections are
+ # unique per config entry - only remove the deleted device owned by this
+ # device's config entry, so another entry can still restore its own.
+ if (
+ deleted_device.config_entry_id == effective_config_entry_id
+ and deleted_device.id in self.deleted_devices
+ ):
+ del self.deleted_devices[deleted_device.id]
# If its only run time attributes (suggested_area)
# that do not get saved we do not want to write
@@ -1374,7 +2131,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
add_config_subentry_id: str | None | UndefinedType = UNDEFINED,
area_id: str | None | UndefinedType = UNDEFINED,
configuration_url: str | URL | None | UndefinedType = UNDEFINED,
- device_info_type: str | UndefinedType = UNDEFINED,
disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED,
entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED,
hw_version: str | None | UndefinedType = UNDEFINED,
@@ -1386,6 +2142,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
model_id: str | None | UndefinedType = UNDEFINED,
name_by_user: str | None | UndefinedType = UNDEFINED,
name: str | None | UndefinedType = UNDEFINED,
+ new_config_entry_id: str | UndefinedType = UNDEFINED,
+ new_config_subentry_id: str | None | UndefinedType = UNDEFINED,
new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
remove_config_entry_id: str | UndefinedType = UNDEFINED,
@@ -1398,11 +2156,57 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
) -> DeviceEntry | None:
"""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_entry_id
+ A device belongs to a single config entry and subentry. To move a device to
+ another config entry or subentry, pass new_config_entry_id and/or
+ new_config_subentry_id. To remove a device, pass remove_config_entry_id with the
+ device's config entry.
+
+ :param add_config_entry_id: Deprecated. Combined with remove_config_entry_id it
+ moves the device; on its own it does nothing.
+ :param add_config_subentry_id: Deprecated. Combined with remove_config_subentry_id
+ it moves the device to another subentry; on its own it does nothing.
+ :param new_config_entry_id: Move the device to this config entry.
+ :param new_config_subentry_id: Move the device to this subentry.
+ :param remove_config_entry_id: Remove the device if it is the device's config
+ entry, unless combined with add_config_entry_id to move the device.
+ :param remove_config_subentry_id: Remove the device from a specific subentry of
+ remove_config_entry_id.
"""
+ if (
+ underlying_ids := self._async_device_ids_for_composite_device_id(device_id)
+ ) is not None:
+ # Fan the update out to each underlying device; keep in sync with the
+ # update parameters above.
+ update_args = {
+ "add_config_entry_id": add_config_entry_id,
+ "add_config_subentry_id": add_config_subentry_id,
+ "area_id": area_id,
+ "configuration_url": configuration_url,
+ "disabled_by": disabled_by,
+ "entry_type": entry_type,
+ "hw_version": hw_version,
+ "labels": labels,
+ "manufacturer": manufacturer,
+ "merge_connections": merge_connections,
+ "merge_identifiers": merge_identifiers,
+ "model": model,
+ "model_id": model_id,
+ "name_by_user": name_by_user,
+ "name": name,
+ "new_config_entry_id": new_config_entry_id,
+ "new_config_subentry_id": new_config_subentry_id,
+ "new_connections": new_connections,
+ "new_identifiers": new_identifiers,
+ "remove_config_entry_id": remove_config_entry_id,
+ "remove_config_subentry_id": remove_config_subentry_id,
+ "serial_number": serial_number,
+ "suggested_area": suggested_area,
+ "sw_version": sw_version,
+ "via_device_id": via_device_id,
+ }
+ return self._async_update_composite_device(
+ device_id, underlying_ids, update_args
+ )
if suggested_area is not UNDEFINED:
report_usage(
"passes a suggested_area to device_registry.async_update device",
@@ -1425,7 +2229,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
add_config_entry_id=add_config_entry_id,
add_config_subentry_id=add_config_subentry_id,
area_id=area_id,
- device_info_type=device_info_type,
disabled_by=disabled_by,
entry_type=entry_type,
labels=labels,
@@ -1433,6 +2236,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
merge_identifiers=merge_identifiers,
name_by_user=name_by_user,
name=name,
+ new_config_entry_id=new_config_entry_id,
+ new_config_subentry_id=new_config_subentry_id,
new_connections=new_connections,
new_identifiers=new_identifiers,
remove_config_entry_id=remove_config_entry_id,
@@ -1446,10 +2251,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
def _validate_connections(
self,
device_id: str,
+ config_entry_id: str,
connections: set[tuple[str, str]],
allow_collisions: bool,
) -> set[tuple[str, str]]:
- """Normalize and validate connections, raise on collision with other devices."""
+ """Normalize and validate connections, raise on collision with other devices.
+
+ Connections are unique per config entry, so only collisions with other devices
+ of the same config entry are considered.
+ """
normalized_connections = _normalize_connections(connections)
if allow_collisions:
return normalized_connections
@@ -1459,7 +2269,9 @@ 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.devices.get_entry(connections={connection})
+ existing_device := self.devices.get_entry(
+ connections={connection}, config_entry_id=config_entry_id
+ )
) and existing_device.id != device_id:
raise DeviceConnectionCollisionError(
normalized_connections, existing_device
@@ -1471,10 +2283,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
def _validate_identifiers(
self,
device_id: str,
+ config_entry_id: str,
identifiers: set[tuple[str, str]],
allow_collisions: bool,
) -> set[tuple[str, str]]:
- """Validate identifiers, raise on collision with other devices."""
+ """Validate identifiers, raise on collision with other devices.
+
+ Identifiers are unique per config entry, so only collisions with other devices
+ of the same config entry are considered.
+ """
if allow_collisions:
return identifiers
@@ -1483,21 +2300,70 @@ 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.devices.get_entry(identifiers={identifier})
+ existing_device := self.devices.get_entry(
+ identifiers={identifier}, config_entry_id=config_entry_id
+ )
) and existing_device.id != device_id:
raise DeviceIdentifierCollisionError(identifiers, existing_device)
return identifiers
+ @callback
+ def _async_update_composite_device(
+ self,
+ composite_id: str,
+ underlying_ids: list[str],
+ update_args: dict[str, Any],
+ ) -> DeviceEntry | None:
+ """Fan an async_update_device call on a composite out to its real devices."""
+ forward = {
+ name: value for name, value in update_args.items() if value is not UNDEFINED
+ }
+ if ignored := [
+ name for name in _COMPOSITE_IGNORED_UPDATE_ARGS if name in forward
+ ]:
+ # These rewrite a device's functional identity or move it, which is ambiguous
+ # across the composite's underlying devices; drop them rather than corrupt or
+ # collide, and report the offending integration.
+ report_usage(
+ f"passed {', '.join(ignored)} to device_registry.async_update_device "
+ "for a composite device that spans several config entries (returned for "
+ "an ambiguous async_get_device lookup, or "
+ "resolved from a stored device id of a pre-migration composite); the "
+ "argument cannot be applied to the merged device and was ignored - "
+ "target a single device, e.g. one returned by "
+ "async_entries_for_config_entry",
+ core_behavior=ReportBehavior.LOG,
+ )
+ for name in ignored:
+ del forward[name]
+ for underlying_id in underlying_ids:
+ self.async_update_device(underlying_id, **forward)
+ remaining = [
+ self.devices[underlying_id]
+ for underlying_id in underlying_ids
+ if underlying_id in self.devices
+ ]
+ if not remaining:
+ return None
+ return self._restore_composite_device(composite_id, remaining)
+
@callback
def async_remove_device(self, device_id: str) -> None:
"""Remove a device from the device registry."""
+ if (
+ underlying_ids := self._async_device_ids_for_composite_device_id(device_id)
+ ) is not None:
+ for underlying_id in underlying_ids:
+ self.async_remove_device(underlying_id)
+ return
self.hass.verify_event_loop_thread("device_registry.async_remove_device")
device = self.devices.pop(device_id)
+ config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id)
self.deleted_devices[device_id] = DeletedDeviceEntry(
area_id=device.area_id,
- config_entries=device.config_entries,
- config_entries_subentries=device.config_entries_subentries,
+ config_entry_id=device.config_entry_id,
+ config_subentry_id=device.config_subentry_id,
connections=device.connections,
created_at=device.created_at,
disabled_by=device.disabled_by,
@@ -1507,6 +2373,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
modified_at=utcnow(),
name_by_user=device.name_by_user,
orphaned_timestamp=None,
+ domain=config_entry.domain if config_entry is not None else None,
)
for other_device in list(self.devices.values()):
if other_device.via_device_id == device_id:
@@ -1530,19 +2397,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
data = await self._store.async_load()
devices = ActiveDeviceRegistryItems()
- deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] = DeviceRegistryItems()
+ deleted_devices = DeletedDeviceRegistryItems()
if data is not None:
for device in data["devices"]:
devices[device["id"]] = DeviceEntry(
area_id=device["area_id"],
- 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()
- },
+ config_entry_id=device["config_entry_id"],
+ config_subentry_id=device["config_subentry_id"],
configuration_url=device["configuration_url"],
# type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625
connections={
@@ -1567,13 +2429,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
for iden in device["identifiers"]
},
labels=set(device["labels"]),
+ composite_device_id=device["composite_device_id"],
+ composite_primary_config_entry=device[
+ "composite_primary_config_entry"
+ ],
+ split_at=(
+ datetime.fromisoformat(device["split_at"])
+ if device["split_at"]
+ else None
+ ),
manufacturer=device["manufacturer"],
model=device["model"],
model_id=device["model_id"],
modified_at=datetime.fromisoformat(device["modified_at"]),
name_by_user=device["name_by_user"],
name=device["name"],
- primary_config_entry=device["primary_config_entry"],
+ has_composite_identifiers=device["has_composite_identifiers"],
serial_number=device["serial_number"],
sw_version=device["sw_version"],
via_device_id=device["via_device_id"],
@@ -1596,13 +2467,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
for device in data["deleted_devices"]:
deleted_devices[device["id"]] = DeletedDeviceEntry(
area_id=device["area_id"],
- 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()
- },
+ config_entry_id=device["config_entry_id"],
+ config_subentry_id=device["config_subentry_id"],
connections={tuple(conn) for conn in device["connections"]},
created_at=datetime.fromisoformat(device["created_at"]),
disabled_by=get_optional_enum(
@@ -1616,6 +2482,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
modified_at=datetime.fromisoformat(device["modified_at"]),
name_by_user=device["name_by_user"],
orphaned_timestamp=device["orphaned_timestamp"],
+ domain=device["domain"],
)
self.devices = devices
@@ -1645,83 +2512,110 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
}
@callback
- def async_clear_config_entry(self, config_entry_id: str) -> None:
+ def _resolve_orphan_domain(
+ self, config_entry_id: str, domain: str | None
+ ) -> str | None:
+ """Return the domain to record on devices orphaned from a config entry."""
+ if domain is not None:
+ return domain
+ if (
+ entry := self.hass.config_entries.async_get_entry(config_entry_id)
+ ) is not None:
+ return entry.domain
+ return None
+
+ @callback
+ def _async_orphan_deleted_device(
+ self, deleted_device: DeletedDeviceEntry, domain: str | None, now_time: float
+ ) -> None:
+ """Mark a deleted device as orphaned, remembering its former domain."""
+ if domain is not None:
+ # Orphans are indexed by their recorded domain, so two orphans of the
+ # same domain sharing an identifier or connection would collide. When a
+ # device from the same integration is orphaned, drop any existing orphan
+ # it overlaps so the newest one wins deterministically instead of shadowing
+ # it.
+ for existing in list(self.deleted_devices.values()):
+ if (
+ existing.config_entry_id is None
+ and existing.domain == domain
+ and (
+ existing.connections & deleted_device.connections
+ or existing.identifiers & deleted_device.identifiers
+ )
+ ):
+ del self.deleted_devices[existing.id]
+ self.deleted_devices[deleted_device.id] = attr.evolve(
+ deleted_device,
+ config_entry_id=None,
+ config_subentry_id=None,
+ orphaned_timestamp=now_time,
+ domain=domain,
+ )
+ self.async_schedule_save()
+
+ @callback
+ def async_clear_config_entry(
+ self, config_entry_id: str, domain: str | None = None
+ ) -> None:
"""Clear config entry from registry entries."""
+ domain = self._resolve_orphan_domain(config_entry_id, domain)
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)
+ self.async_remove_device(device.id)
+ # A split device records the composite's former primary config entry; when that
+ # config entry is removed, clear the now-dangling reference so a restored
+ # composite no longer points at a config entry that no longer exists.
+ for device in list(self.devices.values()):
+ if device.composite_primary_config_entry == config_entry_id:
+ self.devices[device.id] = attr.evolve(
+ device, composite_primary_config_entry=None
+ )
+ self.async_schedule_save()
+ # A device owned by another config entry may hold a transient pending move
+ # targeting the entry being removed; clear it so a later completion deletes the
+ # device instead of moving it onto the removed entry.
+ for device in list(self.devices.values()):
+ pending_move = device._pending_move # noqa: SLF001
+ if (
+ pending_move is not None
+ and pending_move.config_entry_id == config_entry_id
+ ):
+ self.devices[device.id] = attr.evolve(device, pending_move=None)
for deleted_device in list(self.deleted_devices.values()):
- config_entries = deleted_device.config_entries
- if config_entry_id not in config_entries:
+ if deleted_device.config_entry_id != config_entry_id:
continue
- 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(),
- 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,
- config_entries_subentries=config_entries_subentries,
- )
- self.async_schedule_save()
+ self._async_orphan_deleted_device(deleted_device, domain, now_time)
@callback
def async_clear_config_subentry(
- self, config_entry_id: str, config_subentry_id: str
+ self, config_entry_id: str, config_subentry_id: str, domain: str | None = None
) -> None:
- """Clear config entry from registry entries."""
+ """Clear config subentry from registry entries."""
+ domain = self._resolve_orphan_domain(config_entry_id, domain)
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 device.config_subentry_id != config_subentry_id:
+ continue
+ self.async_remove_device(device.id)
+ # A device may hold a transient pending move targeting the subentry being removed;
+ # clear it so a later completion deletes the device instead of validating against
+ # the removed subentry.
+ for device in list(self.devices.values()):
+ pending_move = device._pending_move # noqa: SLF001
if (
- config_entry_id not in config_entries_subentries
- or config_subentry_id not in config_entries_subentries[config_entry_id]
+ pending_move is not None
+ and pending_move.config_entry_id == config_entry_id
+ and pending_move.config_subentry_id == config_subentry_id
+ ):
+ self.devices[device.id] = attr.evolve(device, pending_move=None)
+ for deleted_device in list(self.deleted_devices.values()):
+ if (
+ deleted_device.config_entry_id != config_entry_id
+ or deleted_device.config_subentry_id != config_subentry_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()
+ self._async_orphan_deleted_device(deleted_device, domain, now_time)
@callback
def async_purge_expired_orphaned_devices(self) -> None:
@@ -1821,7 +2715,6 @@ def async_config_entry_disabled_by_changed(
the config entry is disabled, enable devices in the registry that are associated
with a config entry when the config entry is enabled and the devices are marked
DeviceEntryDisabler.CONFIG_ENTRY.
- Only disable a device if all associated config entries are disabled.
"""
devices = async_entries_for_config_entry(registry, config_entry.entry_id)
@@ -1833,25 +2726,37 @@ def async_config_entry_disabled_by_changed(
registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001
return
- enabled_config_entries = {
- entry.entry_id
- for entry in registry.hass.config_entries.async_entries()
- if not entry.disabled_by
- }
-
for device in devices:
if device.disabled:
# Device already disabled, do not overwrite
continue
- if len(device.config_entries) > 1 and device.config_entries.intersection(
- enabled_config_entries
- ):
- continue
registry._async_update_device( # noqa: SLF001
device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY
)
+@callback
+def _migrate_device_disabled_by(
+ device: dict[str, Any], config_entry_disabled: bool
+) -> None:
+ """Reconcile a stored device's disabled_by with its config entry's disabled state.
+
+ Reimplements async_config_entry_disabled_by_changed on stored data so the 1.13
+ migration can fix a split device that inherited the composite's disabled_by. Kept in
+ lockstep with that function by test_migrate_device_disabled_by_matches_runtime; can be
+ removed in HA Core 2027.8.
+ """
+ disabled_by = device["disabled_by"]
+ if not config_entry_disabled:
+ # Config entry enabled: drop a config-entry disable, keep a user/integration one
+ if disabled_by == DeviceEntryDisabler.CONFIG_ENTRY:
+ device["disabled_by"] = None
+ return
+ # Config entry disabled: disable the device unless it is already disabled
+ if disabled_by is None:
+ device["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY
+
+
@callback
def async_cleanup(
hass: HomeAssistant,
@@ -1864,8 +2769,7 @@ def async_cleanup(
references_config_entries = {
device.id
for device in dev_reg.devices.values()
- for config_entry_id in device.config_entries
- if config_entry_id in config_entry_ids
+ if device.config_entry_id in config_entry_ids
}
# Find all devices that are referenced in the entity registry.
@@ -1883,11 +2787,10 @@ def async_cleanup(
# Find all referenced config entries that no longer exist
# This shouldn't happen but have not been able to track down the bug :(
for device in list(dev_reg.devices.values()):
- for config_entry_id in device.config_entries:
- if config_entry_id not in config_entry_ids:
- dev_reg._async_update_device( # noqa: SLF001
- device.id, remove_config_entry_id=config_entry_id
- )
+ if device.config_entry_id not in config_entry_ids:
+ dev_reg._async_update_device( # noqa: SLF001
+ device.id, remove_config_entry_id=device.config_entry_id
+ )
# Periodic purge of orphaned devices to avoid the registry
# growing without bounds when there are lots of deleted devices
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index 733482d58e2f..f4c4366d1b5a 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -38,7 +38,6 @@ from homeassistant.util.async_ import create_eager_task
from homeassistant.util.hass_dict import HassKey
from . import device_registry as dr, entity_registry as er, service, translation
-from .deprecation import deprecated_function
from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider
from .event import async_call_later
from .frame import report_usage
@@ -1233,77 +1232,6 @@ class EntityPlatform:
"""Return the platform name (e.g hue)."""
return self.platform_data.platform_name
- @property
- @deprecated_function(
- "platform_data.component_translations",
- breaks_in_ha_version="2026.8",
- )
- def component_translations(self) -> dict[str, str]:
- """Return the component translations.
-
- Will be removed in Home Assistant Core 2026.8.
- """
- return self.platform_data.component_translations
-
- @property
- @deprecated_function(
- "platform_data.platform_translations",
- breaks_in_ha_version="2026.8",
- )
- def platform_translations(self) -> dict[str, str]:
- """Return the platform translations.
-
- Will be removed in Home Assistant Core 2026.8.
- """
- return self.platform_data.platform_translations
-
- @property
- @deprecated_function(
- "platform_data.object_id_component_translations",
- breaks_in_ha_version="2026.8",
- )
- def object_id_component_translations(self) -> dict[str, str]:
- """Return the object ID component translations.
-
- Will be removed in Home Assistant Core 2026.8.
- """
- return self.platform_data.object_id_component_translations
-
- @property
- @deprecated_function(
- "platform_data.object_id_platform_translations",
- breaks_in_ha_version="2026.8",
- )
- def object_id_platform_translations(self) -> dict[str, str]:
- """Return the object ID platform translations.
-
- Will be removed in Home Assistant Core 2026.8.
- """
- return self.platform_data.object_id_platform_translations
-
- @property
- @deprecated_function(
- "platform_data.default_language_platform_translations",
- breaks_in_ha_version="2026.8",
- )
- def default_language_platform_translations(self) -> dict[str, str]:
- """Return the default language platform translations.
-
- Will be removed in Home Assistant Core 2026.8.
- """
- return self.platform_data.default_language_platform_translations
-
- @deprecated_function(
- "platform_data.async_load_translations",
- breaks_in_ha_version="2026.8",
- )
- async def async_load_translations(self) -> None:
- """Load translations.
-
- Will be removed in Home Assistant Core 2026.8.
- """
- return await self.platform_data.async_load_translations()
-
@overload
def _async_derive_object_ids(
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index 3683385f7b0a..092976d6e382 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -934,9 +934,14 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]):
Also maintains a count of enabled entries per config entry id.
"""
- def __init__(self) -> None:
+ def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the container."""
super().__init__()
+ # hass is stored only so get_entries_for_device_id can expand a pre-migration
+ # composite device id to its split devices. Remove it, and restore the no-argument
+ # constructor, once the device registry deprecation period is over and composite
+ # device ids are no longer resolved.
+ self._hass = hass
self._entry_ids: dict[str, RegistryEntry] = {}
self._index: dict[tuple[str, str, str], str] = {}
self._config_entry_id_index: RegistryIndexType = defaultdict(dict)
@@ -1002,13 +1007,36 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]):
return self._entry_ids.get(key)
def get_entries_for_device_id(
- self, device_id: str, include_disabled_entities: bool = False
+ self,
+ device_id: str,
+ include_disabled_entities: bool = False,
) -> list[RegistryEntry]:
- """Get entries for device."""
+ """Get entries for device.
+
+ A device_id may be a pre-migration composite device id, which was split into one
+ device per config entry. The entries of the split devices are included, so a
+ lookup by the old composite id still finds the entities that were moved to the
+ split devices.
+ """
data = self.data
+ if keys := self._device_id_index.get(device_id):
+ # Entities are indexed only under real (live or just-removed) device ids,
+ # never under a composite device id, so a non-empty bucket means the direct
+ # result is complete and the device registry can be skipped.
+ return [
+ entry
+ for key in keys
+ if not (entry := data[key]).disabled_by or include_disabled_entities
+ ]
+ # No directly indexed entities: device_id may be a pre-migration composite device
+ # id, which resolves to the entities of the split devices it was migrated into.
+ device_registry = dr.async_get(self._hass)
return [
entry
- for key in self._device_id_index.get(device_id, ())
+ for device in device_registry.async_get_devices_for_composite_device_id(
+ device_id
+ )
+ for key in self._device_id_index.get(device.id, ())
if not (entry := data[key]).disabled_by or include_disabled_entities
]
@@ -1095,7 +1123,7 @@ def _validate_item(
)
if device_id and device_id is not UNDEFINED:
device_registry = dr.async_get(hass)
- if not device_registry.async_get(device_id):
+ if device_id not in device_registry.devices:
raise ValueError(f"Device {device_id} does not exist")
if (
disabled_by
@@ -1629,36 +1657,32 @@ class EntityRegistry(BaseRegistry):
changes = event.data["changes"]
- # Remove entities which belong to config entries no longer associated with the
- # device
- if old_config_entries := changes.get("config_entries"):
+ # Remove entities which belong to the config entry the device no longer belongs
+ # to. changes carries the old config_entry_id only when it changed (a move).
+ if "config_entry_id" in changes:
+ old_config_entry_id = changes["config_entry_id"]
entities = async_entries_for_device(
self, event.data["device_id"], include_disabled_entities=True
)
for entity in entities:
- config_entry_id = entity.config_entry_id
if (
- entity.config_entry_id in old_config_entries
- and entity.config_entry_id not in device.config_entries
+ entity.config_entry_id == old_config_entry_id
+ and entity.config_entry_id != device.config_entry_id
):
self.async_remove(entity.entity_id)
- # Remove entities which belong to config subentries no longer
- # associated with the device
- if old_config_entries_subentries := changes.get("config_entries_subentries"):
+ # Remove entities which belong to the config subentry the device no longer
+ # belongs to. changes carries the old config_subentry_id only when it changed.
+ if "config_subentry_id" in changes:
+ old_config_subentry_id = changes["config_subentry_id"]
entities = async_entries_for_device(
self, event.data["device_id"], include_disabled_entities=True
)
for entity in entities:
- config_entry_id = entity.config_entry_id
- config_subentry_id = entity.config_subentry_id
if (
- config_entry_id in device.config_entries
- and config_entry_id in old_config_entries_subentries
- and config_subentry_id
- in old_config_entries_subentries[config_entry_id]
- and config_subentry_id
- not in device.config_entries_subentries[config_entry_id]
+ entity.config_entry_id == device.config_entry_id
+ and entity.config_subentry_id == old_config_subentry_id
+ and entity.config_subentry_id != device.config_subentry_id
):
self.async_remove(entity.entity_id)
@@ -2011,16 +2035,53 @@ class EntityRegistry(BaseRegistry):
async def _async_load(self) -> None:
"""Load the entity registry."""
# Device registry must be loaded before entity registry because
- # migration and entity processing reference device names.
- await dr.async_get(self.hass).async_wait_loaded()
+ # migration and entity processing reference device names, and because entities
+ # are moved to the correct device when a pre-migration composite device was
+ # split into one device per config entry.
+ device_registry = dr.async_get(self.hass)
+ await device_registry.async_wait_loaded()
_async_setup_cleanup(self.hass, self)
_async_setup_entity_restore(self.hass, self)
data = await self._store.async_load()
- entities = EntityRegistryItems()
+ entities = EntityRegistryItems(self.hass)
deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] = {}
+ # Move entities to the correct device when a pre-migration composite device was
+ # split into one device per config entry. This can be removed 12 months after
+ # the config entries split migration ships.
+ migrated_composite_device = False
+
+ def _split_device_id(
+ device_id: str | None,
+ config_entry_id: str | None,
+ config_subentry_id: str | None,
+ ) -> str | None:
+ """Map a device id to the split device matching the entity's config entry."""
+ # Note: check container membership, not async_get, which returns a restored
+ # composite for a composite device id
+ if device_id is None or device_id in device_registry.devices:
+ return device_id
+ successors = device_registry.async_get_devices_for_composite_device_id(
+ device_id
+ )
+ if not successors:
+ # The device is gone (e.g. the migration dropped a device with no config
+ # entry) and was not split; detach the entity rather than leave it pointing
+ # at a device id that no longer exists.
+ return None
+ for successor in successors:
+ if (
+ successor.config_entry_id == config_entry_id
+ and successor.config_subentry_id == config_subentry_id
+ ):
+ return successor.id
+ for successor in successors:
+ if successor.config_entry_id == config_entry_id:
+ return successor.id
+ return successors[0].id
+
if data is not None:
for entity in data["entities"]:
try:
@@ -2048,11 +2109,19 @@ class EntityRegistry(BaseRegistry):
)
continue
+ device_id = _split_device_id(
+ entity["device_id"],
+ entity["config_entry_id"],
+ entity["config_subentry_id"],
+ )
+ if device_id != entity["device_id"]:
+ migrated_composite_device = True
+
original_name_unprefixed = _unprefix_original_name(
self.hass,
entity["original_name"],
entity["has_entity_name"],
- entity["device_id"],
+ device_id,
)
entities[entity["entity_id"]] = RegistryEntry(
@@ -2065,7 +2134,7 @@ class EntityRegistry(BaseRegistry):
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
device_class=entity["device_class"],
- device_id=entity["device_id"],
+ device_id=device_id,
disabled_by=RegistryEntryDisabler(entity["disabled_by"])
if entity["disabled_by"]
else None,
@@ -2164,6 +2233,10 @@ class EntityRegistry(BaseRegistry):
self.entities = entities
self._entities_data = entities.data
+ # Persist entities moved off a split pre-migration composite device
+ if migrated_composite_device:
+ self.async_schedule_save()
+
@override
def _data_to_save(self) -> dict[str, Any]:
"""Return data of entity registry to store in a file."""
@@ -2300,7 +2373,11 @@ async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
def async_entries_for_device(
registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False
) -> list[RegistryEntry]:
- """Return entries that match a device."""
+ """Return entries that match a device.
+
+ A pre-migration composite device id resolves to the entries of the devices it was
+ split into.
+ """
return registry.entities.get_entries_for_device_id(
device_id, include_disabled_entities
)
diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py
index c433040a6c56..ba9f5191b6c2 100644
--- a/homeassistant/helpers/helper_integration.py
+++ b/homeassistant/helpers/helper_integration.py
@@ -7,17 +7,18 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, valid_entity
from . import device_registry as dr, entity_registry as er
from .event import async_track_entity_registry_updated_event
+from .frame import ReportBehavior, report_usage
def async_handle_source_entity_changes(
hass: HomeAssistant,
*,
- add_helper_config_entry_to_device: bool = True,
helper_config_entry_id: str,
set_source_entity_id_or_uuid: Callable[[str], None],
source_device_id: str | None,
source_entity_id_or_uuid: str,
source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None,
+ **kwargs: Any,
) -> CALLBACK_TYPE:
"""Handle changes to a helper entity's source entity.
@@ -31,11 +32,9 @@ def async_handle_source_entity_changes(
called. If the source entity is identified by a UUID, the helper config entry
is reloaded.
- Source entity moved to another device: The helper entity is updated to link
- to the new device, and the helper config entry removed from the old device
- and added to the new device. Then the helper config entry is reloaded.
+ to the new device. Then the helper config entry is reloaded.
- Source entity removed from the device: The helper entity is updated to link
- to no device, and the helper config entry removed from the old device. Then
- the helper config entry is reloaded.
+ to no device. Then the helper config entry is reloaded.
:param set_source_entity_id_or_uuid: A function which updates the source entity
ID or UUID, e.g., in the helper config entry options.
@@ -43,6 +42,22 @@ def async_handle_source_entity_changes(
is removed. This can be used to clean up any resources related to the source
entity or ask the user to select a new source entity.
"""
+ if "add_helper_config_entry_to_device" in kwargs:
+ del kwargs["add_helper_config_entry_to_device"]
+ # Adding the helper's config entry to the source device is no longer supported
+ # now that a device belongs to a single config entry; the helper entities link to
+ # the source device via their device_id instead.
+ report_usage(
+ "calls async_handle_source_entity_changes with "
+ "add_helper_config_entry_to_device, which no longer has any effect",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2027.8.0",
+ )
+ if kwargs:
+ raise TypeError(
+ "async_handle_source_entity_changes() got unexpected keyword arguments "
+ f"{', '.join(map(repr, kwargs))}"
+ )
async def async_registry_updated(
event: Event[er.EventEntityRegistryUpdatedData],
@@ -89,9 +104,8 @@ def async_handle_source_entity_changes(
# No need to do any cleanup
return
- # The source entity has been moved to a different device, update the helper
- # entities to link to the new device and the helper device to include the
- # helper config entry
+ # The source entity has been moved to a different device; relink the helper
+ # entities to the new device.
for helper_entity in entity_registry.entities.get_entries_for_config_entry_id(
helper_config_entry_id
):
@@ -100,17 +114,6 @@ def async_handle_source_entity_changes(
helper_entity.entity_id, device_id=source_entity_entry.device_id
)
- if add_helper_config_entry_to_device:
- if source_entity_entry.device_id is not None:
- device_registry.async_update_device(
- source_entity_entry.device_id,
- add_config_entry_id=helper_config_entry_id,
- )
-
- device_registry.async_update_device(
- source_device_id, remove_config_entry_id=helper_config_entry_id
- )
-
source_device_id = source_entity_entry.device_id
# Reload the config entry so the helper entity is recreated with
diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py
index f93009cbeb39..a380cec1ae31 100644
--- a/homeassistant/helpers/http.py
+++ b/homeassistant/helpers/http.py
@@ -27,6 +27,10 @@ from .json import find_paths_unserializable_data, json_bytes, json_dumps
_LOGGER = logging.getLogger(__name__)
+# Responses smaller than this fit within a single network packet, so
+# compressing them wastes event-loop CPU without reducing round-trips.
+MIN_COMPRESSED_RESPONSE_SIZE: Final = 1024
+
type AllowCorsType = Callable[[AbstractRoute | AbstractResource], None]
KEY_AUTHENTICATED: Final = "ha_authenticated"
@@ -160,7 +164,8 @@ class HomeAssistantView:
headers=headers,
zlib_executor_size=32768,
)
- response.enable_compression()
+ if len(msg) > MIN_COMPRESSED_RESPONSE_SIZE:
+ response.enable_compression()
return response
def json_message(
diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py
index 7fc212c9dba3..236eb7556138 100644
--- a/homeassistant/helpers/integration_platform.py
+++ b/homeassistant/helpers/integration_platform.py
@@ -266,7 +266,7 @@ async def _async_process_integration_platforms(
# Any = platform.
-type ProcessPlatform[_R] = Callable[[HomeAssistant, str, Any], _R]
+type ProcessPlatform[_R] = Callable[[HomeAssistant, str, Any], _R | Awaitable[_R]]
class LazyIntegrationPlatforms[_R]:
@@ -277,6 +277,8 @@ class LazyIntegrationPlatforms[_R]:
this only imports and processes the platform for an integration the first
time it is requested, and only for integrations that are loaded.
+ The process callback may be a coroutine function; its result is awaited.
+
The platform is intentionally not registered for preloading, since for a
rarely used platform that would import it for every integration during
loading, defeating the point of loading it lazily.
@@ -358,7 +360,10 @@ class LazyIntegrationPlatforms[_R]:
result: _R | None = None
if platform is not None:
try:
- result = self._process_platform(self._hass, domain, platform)
+ processed = self._process_platform(self._hass, domain, platform)
+ if isinstance(processed, Awaitable):
+ processed = await processed
+ result = processed
except Exception:
_LOGGER.exception(
"Error processing %s platform for %s",
diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py
index f65c731d12a0..1a8528c8f039 100644
--- a/homeassistant/helpers/intent.py
+++ b/homeassistant/helpers/intent.py
@@ -14,11 +14,7 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.homeassistant.exposed_entities import async_should_expose
-from homeassistant.const import (
- ATTR_DEVICE_CLASS,
- ATTR_ENTITY_ID,
- ATTR_SUPPORTED_FEATURES,
-)
+from homeassistant.const import ATTR_ENTITY_ID, EntityStateAttribute
from homeassistant.core import Context, HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.hass_dict import HassKey
@@ -455,7 +451,9 @@ def _filter_by_features(
yield candidate
continue
- supported_features = candidate.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ supported_features = candidate.state.attributes.get(
+ EntityStateAttribute.SUPPORTED_FEATURES, 0
+ )
if (supported_features & features) == features:
yield candidate
@@ -474,7 +472,7 @@ def _filter_by_device_classes(
yield candidate
continue
- device_class = candidate.state.attributes.get(ATTR_DEVICE_CLASS)
+ device_class = candidate.state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if device_class and (device_class in device_classes):
yield candidate
@@ -811,7 +809,7 @@ def async_match_states(
@callback
def async_test_feature(state: State, feature: int, feature_name: str) -> None:
"""Test if state supports a feature."""
- if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0:
+ if state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) & feature == 0:
raise IntentHandleError(f"Entity {state.name} does not support {feature_name}")
diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py
index f89608f793f7..efc050a2c257 100644
--- a/homeassistant/helpers/llm.py
+++ b/homeassistant/helpers/llm.py
@@ -3,37 +3,20 @@
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field as dc_field
-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, override
+from typing import Any, override
import slugify as unicode_slug
import voluptuous as vol
from voluptuous_openapi import UNSUPPORTED, convert
-from homeassistant.components.calendar import (
- DOMAIN as CALENDAR_DOMAIN,
- SERVICE_GET_EVENTS,
-)
-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
-from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
-from homeassistant.components.sensor import async_rounded_state
-from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices
-from homeassistant.components.weather import INTENT_GET_WEATHER
from homeassistant.const import (
ATTR_DOMAIN,
ATTR_SERVICE,
EVENT_HOMEASSISTANT_CLOSE,
EVENT_SERVICE_REMOVED,
)
-from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id
+from homeassistant.core import Context, Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.util import dt as dt_util, yaml as yaml_util
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType
from homeassistant.util.ulid import ulid_now
@@ -42,18 +25,20 @@ from . import (
area_registry as ar,
config_validation as cv,
device_registry as dr,
- entity_registry as er,
floor_registry as fr,
intent,
selector,
service,
)
+from .deprecation import deprecated_function
from .singleton import singleton
ACTION_PARAMETERS_CACHE: HassKey[
dict[str, dict[str, tuple[str | None, vol.Schema]]]
] = HassKey("llm_action_parameters_cache")
+APIS_CACHE: HassKey[dict[str, API]] = HassKey("llm_apis")
+
LLM_API_ASSIST = "assist"
@@ -67,49 +52,8 @@ 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."
-)
-
-DEVICE_CONTROL_TOOL_USAGE_PROMPT = (
- "When controlling Home Assistant always call the intent tools. "
- "Use HassTurnOn to lock and HassTurnOff to unlock a lock. "
- "When controlling a device, prefer passing just name and domain. "
- "When controlling an area, prefer passing just area name and domain."
-)
-
-DYNAMIC_CONTEXT_PROMPT = (
- "You ARE equipped to answer questions about the"
- " current state of\n"
- "the home using the `GetLiveContext` tool."
- " This is a primary function."
- " Do not state you lack the\n"
- "functionality if the question requires live data.\n"
- "If the user asks about device existence/type"
- ' (e.g., "Do I have lights in the bedroom?"):'
- " Answer\n"
- "from the static context below.\n"
- "If the user asks about the CURRENT state, value,"
- ' or mode (e.g., "Is the lock locked?",\n'
- '"Is the fan on?",'
- ' "What mode is the thermostat in?",'
- ' "What is the temperature outside?"):\n'
- " 1. Recognize this requires live data.\n"
- " 2. You MUST call `GetLiveContext`."
- " This tool will provide the needed real-time"
- " information (like temperature from the local"
- " weather, lock status, etc.).\n"
- " 3. Use the tool's response** to answer the"
- " user accurately"
- ' (e.g., "The temperature outside is'
- ' [value from tool].").\n'
- "For general knowledge questions not about the"
- " home: Answer truthfully from internal"
- " knowledge.\n"
-)
-
+@deprecated_function("an empty string", breaks_in_ha_version="2027.2")
@callback
def async_render_no_api_prompt(hass: HomeAssistant) -> str:
"""Return the prompt to be used when no API is configured.
@@ -119,13 +63,15 @@ def async_render_no_api_prompt(hass: HomeAssistant) -> str:
return ""
-@singleton("llm")
+@singleton(APIS_CACHE)
@callback
def _async_get_apis(hass: HomeAssistant) -> dict[str, API]:
- """Get all the LLM APIs."""
- return {
- LLM_API_ASSIST: AssistAPI(hass=hass),
- }
+ """Return the registry of LLM APIs.
+
+ APIs are registered by their owning integration; the Assist API is
+ registered by the ``llm`` integration during setup.
+ """
+ return {}
@callback
@@ -191,7 +137,7 @@ class LLMContext:
language: str | None
"""Language of the LLM request."""
- assistant: str | None
+ assistant: str
"""Assistant domain that is handling the LLM request."""
device_id: str | None
@@ -458,308 +404,6 @@ class MergedAPI(API):
return merged
-class AssistAPI(API):
- """API exposing Assist API to LLMs."""
-
- IGNORE_INTENTS = {
- intent.INTENT_GET_TEMPERATURE,
- INTENT_GET_WEATHER,
- INTENT_OPEN_COVER, # deprecated
- INTENT_CLOSE_COVER, # deprecated
- intent.INTENT_GET_STATE,
- intent.INTENT_NEVERMIND,
- intent.INTENT_TOGGLE,
- intent.INTENT_GET_CURRENT_DATE,
- intent.INTENT_GET_CURRENT_TIME,
- intent.INTENT_RESPOND,
- }
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Init the class."""
- super().__init__(
- hass=hass,
- id=LLM_API_ASSIST,
- name="Assist",
- )
- self.cached_slugify = cache(
- partial(unicode_slug.slugify, separator="_", lowercase=False)
- )
-
- @override
- async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance:
- """Return the instance of the API."""
- if llm_context.assistant:
- exposed_entities: dict | None = _get_exposed_entities(
- self.hass, llm_context.assistant, include_state=False
- )
- else:
- exposed_entities = None
-
- return APIInstance(
- api=self,
- api_prompt=self._async_get_api_prompt(llm_context, exposed_entities),
- llm_context=llm_context,
- tools=self._async_get_tools(llm_context, exposed_entities),
- custom_serializer=selector_serializer,
- )
-
- @callback
- def _async_get_api_prompt(
- self, llm_context: LLMContext, exposed_entities: dict | None
- ) -> str:
- if not exposed_entities or not exposed_entities["entities"]:
- return NO_ENTITIES_PROMPT
-
- # Collect all parts, filtering out any None values
- prompt_parts = [
- DEVICE_CONTROL_TOOL_USAGE_PROMPT,
- DYNAMIC_CONTEXT_PROMPT,
- *self._async_get_exposed_entities_prompt(exposed_entities),
- self._async_get_voice_satellite_area_prompt(llm_context),
- self._async_get_no_timer_prompt(llm_context),
- ]
-
- # Filter out None and empty strings before joining
- return "\n".join([part for part in prompt_parts if part])
-
- @callback
- def _async_get_no_timer_prompt(self, llm_context: LLMContext) -> str | None:
- if not llm_context.device_id or not async_device_supports_timers(
- self.hass, llm_context.device_id
- ):
- return "This device is not able to start timers."
- return None
-
- @callback
- def _async_get_voice_satellite_area_prompt(self, llm_context: LLMContext) -> str:
- """Return the area prompt for the voice satellite."""
- floor: fr.FloorEntry | None = None
- area: ar.AreaEntry | None = None
- extra = ""
- if llm_context.device_id:
- device_reg = dr.async_get(self.hass)
- device = device_reg.async_get(llm_context.device_id)
-
- if device:
- area_reg = ar.async_get(self.hass)
- if device.area_id and (area := area_reg.async_get_area(device.area_id)):
- floor_reg = fr.async_get(self.hass)
- if area.floor_id:
- floor = floor_reg.async_get_floor(area.floor_id)
-
- extra = (
- "and all generic commands like"
- " 'turn on the lights' should target"
- " this area."
- )
-
- if floor and area:
- return f"You are in area {area.name} (floor {floor.name}) {extra}".strip()
- if area:
- return f"You are in area {area.name} {extra}".strip()
- return (
- "When a user asks to turn on all devices of a specific type, "
- "ask the user to specify an area, unless there"
- " is only one device of that type."
- )
-
- @callback
- def _async_get_exposed_entities_prompt(
- self, exposed_entities: dict | None
- ) -> list[str]:
- """Return the prompt for the API for exposed entities."""
- prompt = []
-
- if exposed_entities and exposed_entities["entities"]:
- prompt.append(
- "Static Context: An overview of the areas"
- " and the devices in this smart home:"
- )
- prompt.append(yaml_util.dump(list(exposed_entities["entities"].values())))
-
- return prompt
-
- @callback
- def _async_get_tools(
- self, llm_context: LLMContext, exposed_entities: dict | None
- ) -> list[Tool]:
- """Return a list of LLM tools."""
- ignore_intents = self.IGNORE_INTENTS
- if not llm_context.device_id or not async_device_supports_timers(
- self.hass, llm_context.device_id
- ):
- ignore_intents = ignore_intents | {
- intent.INTENT_START_TIMER,
- intent.INTENT_CANCEL_TIMER,
- intent.INTENT_INCREASE_TIMER,
- intent.INTENT_DECREASE_TIMER,
- intent.INTENT_PAUSE_TIMER,
- intent.INTENT_UNPAUSE_TIMER,
- intent.INTENT_TIMER_STATUS,
- }
-
- intent_handlers = [
- intent_handler
- for intent_handler in intent.async_get(self.hass)
- if intent_handler.intent_type not in ignore_intents
- ]
-
- exposed_domains: set[str] | None = None
- if exposed_entities is not None:
- exposed_domains = {
- info["domain"] for info in exposed_entities["entities"].values()
- }
-
- intent_handlers = [
- intent_handler
- for intent_handler in intent_handlers
- if intent_handler.platforms is None
- or intent_handler.platforms & exposed_domains
- ]
-
- tools: list[Tool] = [
- IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler)
- for intent_handler in intent_handlers
- ]
-
- tools.append(GetDateTimeTool())
-
- if exposed_entities:
- if exposed_entities[CALENDAR_DOMAIN]:
- names = []
- for info in exposed_entities[CALENDAR_DOMAIN].values():
- names.extend(info["names"].split(", "))
- tools.append(CalendarGetEventsTool(names))
-
- if exposed_domains is not None and TODO_DOMAIN in exposed_domains:
- names = []
- for info in exposed_entities["entities"].values():
- if info["domain"] != TODO_DOMAIN:
- continue
- names.extend(info["names"].split(", "))
- tools.append(TodoGetItemsTool(names))
-
- tools.extend(
- ScriptTool(self.hass, script_entity_id)
- 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,
- include_state: bool = True,
-) -> dict[str, dict[str, dict[str, Any]]]:
- """Get exposed entities.
-
- Splits out calendars and scripts.
- """
- area_registry = ar.async_get(hass)
- entity_registry = er.async_get(hass)
- device_registry = dr.async_get(hass)
- interesting_attributes = {
- "temperature",
- "current_temperature",
- "temperature_unit",
- "brightness",
- "humidity",
- "unit_of_measurement",
- "device_class",
- "current_position",
- "percentage",
- "volume_level",
- "media_title",
- "media_artist",
- "media_album_name",
- }
-
- entities = {}
- data: dict[str, dict[str, Any]] = {
- SCRIPT_DOMAIN: {},
- CALENDAR_DOMAIN: {},
- }
-
- for state in sorted(hass.states.async_all(), key=attrgetter("name")):
- if not async_should_expose(hass, assistant, state.entity_id):
- continue
-
- entity_entry = entity_registry.async_get(state.entity_id)
- device_entry = (
- device_registry.async_get(entity_entry.device_id)
- if entity_entry is not None and entity_entry.device_id is not None
- else None
- )
- names = intent.async_get_entity_aliases(hass, entity_entry, state=state)
- area_names = []
-
- if entity_entry is not None:
- if (
- entity_entry.area_id is not None
- and (area_entry := area_registry.async_get_area(entity_entry.area_id))
- is not None
- ):
- # Entity is in area
- area_names.append(area_entry.name)
- area_names.extend(sorted(area_entry.aliases))
- elif device_entry is not None:
- # Check device area
- if (
- device_entry.area_id is not None
- and (
- area_entry := area_registry.async_get_area(device_entry.area_id)
- )
- is not None
- ):
- area_names.append(area_entry.name)
- area_names.extend(sorted(area_entry.aliases))
-
- info: dict[str, Any] = {
- "names": ", ".join(names),
- "domain": state.domain,
- }
-
- if include_state:
- info["state"] = state.state
-
- # Format numeric states with configured display precision
- if state.domain == "sensor":
- info["state"] = async_rounded_state(hass, state.entity_id, state)
-
- # Convert timestamp device_class states from UTC to local time
- if state.attributes.get("device_class") == "timestamp" and state.state:
- if (parsed_utc := dt_util.parse_datetime(state.state)) is not None:
- info["state"] = dt_util.as_local(parsed_utc).isoformat()
-
- if area_names:
- info["areas"] = ", ".join(area_names)
-
- if include_state and (
- attributes := {
- str(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:
- data[state.domain][state.entity_id] = info
- else:
- entities[state.entity_id] = info
-
- data["entities"] = entities
- return data
-
-
def selector_serializer(schema: Any) -> Any: # noqa: C901
"""Convert selectors into OpenAPI schema."""
if not isinstance(schema, selector.Selector):
@@ -957,20 +601,6 @@ def _get_cached_action_parameters(
parameters = vol.Schema(schema)
- if domain == SCRIPT_DOMAIN:
- entity_registry = er.async_get(hass)
- if (
- entity_id := entity_registry.async_get_entity_id(domain, domain, action)
- ) is not None and (
- entity_entry := entity_registry.async_get(entity_id)
- ) is not None:
- aliases = er.async_get_entity_aliases(hass, entity_entry)
- if aliases:
- if description:
- description = description + ". Aliases: " + str(sorted(aliases))
- else:
- description = "Aliases: " + str(sorted(aliases))
-
parameters_cache.setdefault(domain, {})[action] = (description, parameters)
return description, parameters
@@ -1044,335 +674,3 @@ class ActionTool(Tool):
)
return {"success": True, "result": result}
-
-
-class ScriptTool(ActionTool):
- """LLM Tool representing a Script."""
-
- def __init__(
- self,
- hass: HomeAssistant,
- script_entity_id: str,
- ) -> None:
- """Init the class."""
- script_name = split_entity_id(script_entity_id)[1]
-
- action = script_name
- entity_registry = er.async_get(hass)
- entity_entry = entity_registry.async_get(script_entity_id)
- if entity_entry and entity_entry.unique_id:
- action = entity_entry.unique_id
-
- super().__init__(hass, SCRIPT_DOMAIN, action)
-
- self.name = script_name
- if self.name[0].isdigit():
- self.name = "_" + self.name
-
-
-class CalendarGetEventsTool(Tool):
- """LLM Tool allowing querying a calendar."""
-
- name = "calendar_get_events"
- description = (
- "Get events from a calendar. "
- "When asked if something happens, search the whole week. "
- "Results are RFC 5545 which means 'end' is exclusive."
- )
-
- def __init__(self, calendars: list[str]) -> None:
- """Init the get events tool."""
- self.parameters = vol.Schema(
- {
- vol.Required("calendar"): vol.In(calendars),
- vol.Required("range"): vol.In(["today", "week"]),
- }
- )
-
- @override
- async def async_call(
- self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
- ) -> JsonObjectType:
- """Query a calendar."""
- data = self.parameters(tool_input.tool_args)
- result = intent.async_match_targets(
- hass,
- intent.MatchTargetsConstraints(
- name=data["calendar"],
- domains=[CALENDAR_DOMAIN],
- assistant=llm_context.assistant,
- ),
- )
- if not result.is_match:
- return {"success": False, "error": "Calendar not found"}
-
- entity_id = result.states[0].entity_id
- if data["range"] == "today":
- start = dt_util.now()
- end = dt_util.start_of_local_day() + timedelta(days=1)
- elif data["range"] == "week":
- start = dt_util.now()
- end = dt_util.start_of_local_day() + timedelta(days=7)
-
- service_data = {
- "entity_id": entity_id,
- "start_date_time": start.isoformat(),
- "end_date_time": end.isoformat(),
- }
-
- service_result = await hass.services.async_call(
- CALENDAR_DOMAIN,
- SERVICE_GET_EVENTS,
- service_data,
- context=llm_context.context,
- blocking=True,
- return_response=True,
- )
-
- events = [
- event if "T" in event["start"] else {**event, "all_day": True}
- for event in cast(dict, service_result)[entity_id]["events"]
- ]
-
- return {"success": True, "result": events}
-
-
-class TodoGetItemsTool(Tool):
- """LLM Tool allowing querying a to-do list."""
-
- name = "todo_get_items"
- description = (
- "Query a to-do list to find out what items are on it. "
- "Use this to answer questions like "
- "'What's on my task list?' or "
- "'Read my grocery list'. "
- "Filters items by status (needs_action, completed, all)."
- )
-
- def __init__(self, todo_lists: list[str]) -> None:
- """Init the get items tool."""
- self.parameters = vol.Schema(
- {
- vol.Required("todo_list"): vol.In(todo_lists),
- vol.Optional(
- "status",
- description=(
- "Filter returned items by status,"
- " by default returns incomplete"
- " items"
- ),
- default="needs_action",
- ): vol.In(["needs_action", "completed", "all"]),
- }
- )
-
- @override
- async def async_call(
- self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
- ) -> JsonObjectType:
- """Query a to-do list."""
- data = self.parameters(tool_input.tool_args)
- result = intent.async_match_targets(
- hass,
- intent.MatchTargetsConstraints(
- name=data["todo_list"],
- domains=[TODO_DOMAIN],
- assistant=llm_context.assistant,
- ),
- )
- if not result.is_match:
- return {"success": False, "error": "To-do list not found"}
- entity_id = result.states[0].entity_id
- service_data: dict[str, Any] = {"entity_id": entity_id}
- if status := data.get("status"):
- if status == "all":
- service_data["status"] = ["needs_action", "completed"]
- else:
- service_data["status"] = [status]
- service_result = await hass.services.async_call(
- TODO_DOMAIN,
- TodoServices.GET_ITEMS,
- service_data,
- context=llm_context.context,
- blocking=True,
- return_response=True,
- )
- if not service_result:
- return {"success": False, "error": "To-do list not found"}
- items = cast(dict, service_result)[entity_id]["items"]
- return {"success": True, "result": items}
-
-
-def _live_context_match_error(
- match_result: intent.MatchTargetsResult,
- name_filter: str | None,
- area_filter: str | None,
- domain_filter: list[str] | None,
-) -> str:
- """Build an actionable error message for a failed GetLiveContext match."""
- reason = match_result.no_match_reason
- if reason is intent.MatchFailedReason.INVALID_AREA:
- return f"Area '{match_result.no_match_name}' does not exist"
- if reason is intent.MatchFailedReason.NAME:
- return f"No exposed entities matched name '{name_filter}'"
- if reason is intent.MatchFailedReason.AREA:
- return f"No exposed entities found in area '{area_filter}'"
- if reason is intent.MatchFailedReason.DOMAIN:
- domains = ", ".join(domain_filter) if domain_filter else ""
- return f"No exposed entities found in domain(s): {domains}"
- return "No entities matched the provided filter"
-
-
-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 = (
- "Provides real-time information about the"
- " CURRENT state, value, or mode of devices,"
- " sensors, entities, or areas. "
- "Use this tool for: "
- "1. Answering questions about current"
- " conditions (e.g., 'Is the light on?'). "
- "2. As the first step in conditional actions"
- " (e.g., 'If the weather is rainy, turn off"
- " sprinklers' requires checking the weather"
- " first). "
- "You may filter for devices by name, domain,"
- " and area, including combining those"
- " filters. "
- "Prefer filtering by domain when searching"
- " for multiple devices of the same type."
- )
- parameters = vol.Schema(
- {
- vol.Optional(
- "name",
- description="Filter entities by name or alias (case-insensitive).",
- ): cv.string,
- vol.Optional(
- "domain",
- description=(
- "Filter entities by domain"
- " (e.g. 'light', 'sensor')."
- " Accepts a single domain or a list."
- ),
- ): vol.Any(cv.string, [cv.string]),
- vol.Optional(
- "area",
- description="Filter entities by area name or alias (case-insensitive).",
- ): cv.string,
- }
- )
-
- @override
- 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"}
-
- args = self.parameters(tool_input.tool_args)
- exposed_entities = _get_exposed_entities(hass, llm_context.assistant)
-
- if not exposed_entities["entities"]:
- return {"success": False, "error": NO_ENTITIES_PROMPT}
-
- name_filter = args.get("name")
- area_filter = args.get("area")
- domain_filter = args.get("domain")
-
- if isinstance(domain_filter, str):
- domain_filter = [domain_filter]
-
- if domain_filter is not None:
- domain_filter = [
- normalized_domain
- for domain in domain_filter
- if (normalized_domain := domain.strip().lower())
- ]
-
- if name_filter or area_filter or domain_filter:
- exposed_states = [
- state
- for entity_id in exposed_entities["entities"]
- if (state := hass.states.get(entity_id)) is not None
- ]
- match_result = intent.async_match_targets(
- hass,
- intent.MatchTargetsConstraints(
- name=name_filter,
- area_name=area_filter,
- domains=domain_filter,
- # This tool only returns context, so multiple entities
- # sharing a name (e.g. "AC" in two areas) should all be
- # returned rather than failing as an ambiguous match.
- allow_duplicate_names=True,
- ),
- states=exposed_states,
- )
-
- if not match_result.is_match:
- return {
- "success": False,
- "error": _live_context_match_error(
- match_result, name_filter, area_filter, domain_filter
- ),
- }
-
- matched_ids = {state.entity_id for state in match_result.states}
- entities = [
- info
- for entity_id, info in exposed_entities["entities"].items()
- if entity_id in matched_ids
- ]
- else:
- entities = list(exposed_entities["entities"].values())
-
- prompt = [
- "Live Context: An overview of the areas"
- " and the devices in this smart home:",
- yaml_util.dump(entities),
- ]
- return {
- "success": True,
- "result": "\n".join(prompt),
- }
-
-
-class GetDateTimeTool(Tool):
- """Tool for getting the current date and time."""
-
- name = "GetDateTime"
- description = "Provides the current date and time."
-
- @override
- async def async_call(
- self,
- hass: HomeAssistant,
- tool_input: ToolInput,
- llm_context: LLMContext,
- ) -> JsonObjectType:
- """Get the current date and time."""
- now = dt_util.now()
-
- return {
- "success": True,
- "result": {
- "date": now.strftime("%Y-%m-%d"),
- "time": now.strftime("%H:%M:%S"),
- "timezone": now.strftime("%Z"),
- "weekday": now.strftime("%A"),
- },
- }
diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py
index 42c251e72d4f..9d268b74f6d5 100644
--- a/homeassistant/helpers/location.py
+++ b/homeassistant/helpers/location.py
@@ -3,7 +3,7 @@
from collections.abc import Iterable
import logging
-from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant, State
from homeassistant.util import location as location_util
@@ -17,8 +17,12 @@ def has_location(state: State) -> bool:
"""
return (
isinstance(state, State)
- and isinstance(state.attributes.get(ATTR_LATITUDE), (float, int))
- and isinstance(state.attributes.get(ATTR_LONGITUDE), (float, int))
+ and isinstance(
+ state.attributes.get(EntityStateAttribute.LATITUDE), (float, int)
+ )
+ and isinstance(
+ state.attributes.get(EntityStateAttribute.LONGITUDE), (float, int)
+ )
)
@@ -36,8 +40,8 @@ def closest(latitude: float, longitude: float, states: Iterable[State]) -> State
with_location,
key=lambda state: (
location_util.distance(
- state.attributes.get(ATTR_LATITUDE),
- state.attributes.get(ATTR_LONGITUDE),
+ state.attributes.get(EntityStateAttribute.LATITUDE),
+ state.attributes.get(EntityStateAttribute.LONGITUDE),
latitude,
longitude,
)
@@ -72,13 +76,13 @@ def find_coordinates(
# Check if entity_state is a zone
zone_entity = hass.states.get(f"zone.{entity_state.state}")
- if has_location(zone_entity): # type: ignore[arg-type]
+ if zone_entity and has_location(zone_entity):
_LOGGER.debug(
"%s is in %s, getting zone location",
name,
- zone_entity.entity_id, # type: ignore[union-attr]
+ zone_entity.entity_id,
)
- return _get_location_from_attributes(zone_entity) # type: ignore[arg-type]
+ return _get_location_from_attributes(zone_entity)
# Check if entity_state is a friendly name of a zone
if (zone_coords := resolve_zone(hass, entity_state.state)) is not None:
@@ -124,4 +128,7 @@ def resolve_zone(hass: HomeAssistant, zone_name: str) -> str | None:
def _get_location_from_attributes(entity_state: State) -> str:
"""Get the lat/long string from an entities attributes."""
attr = entity_state.attributes
- return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}"
+ return (
+ f"{attr.get(EntityStateAttribute.LATITUDE)},"
+ f"{attr.get(EntityStateAttribute.LONGITUDE)}"
+ )
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 2653d8132821..224d6688f033 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -1,8 +1,7 @@
"""Helpers to execute scripts."""
import asyncio
-from collections.abc import AsyncGenerator, Callable, Mapping, Sequence
-from contextlib import asynccontextmanager
+from collections.abc import Callable, Mapping, Sequence
from contextvars import ContextVar
from copy import copy
from dataclasses import dataclass
@@ -76,6 +75,7 @@ from homeassistant.core import (
State,
SupportsResponse,
callback,
+ valid_entity_id,
)
from homeassistant.util import slugify
from homeassistant.util.async_ import create_eager_task
@@ -186,79 +186,102 @@ def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement:
return trace_element
-@asynccontextmanager
-async def trace_action(
- hass: HomeAssistant,
- script_run: _ScriptRun,
- stop: asyncio.Future[None],
- variables: TemplateVarsType,
-) -> AsyncGenerator[TraceElement]:
+class trace_action:
"""Trace action execution."""
- path = trace_path_get()
- trace_element = action_trace_append(variables, path)
- trace_stack_push(trace_stack_cv, trace_element)
- trace_id = trace_id_get()
- if trace_id:
- key = trace_id[0]
- run_id = trace_id[1]
- breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS]
- if key in breakpoints and (
- (
- run_id in breakpoints[key]
- and (
- path in breakpoints[key][run_id]
- or NODE_ANY in breakpoints[key][run_id]
+ __slots__ = ("_hass", "_stop", "_trace_element", "_variables")
+
+ _trace_element: TraceElement
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ script_run: _ScriptRun,
+ stop: asyncio.Future[None],
+ variables: TemplateVarsType,
+ ) -> None:
+ """Store the data needed to trace the action."""
+ self._hass = hass
+ self._stop = stop
+ self._variables = variables
+
+ async def __aenter__(self) -> TraceElement:
+ """Start tracing the action, handling any configured breakpoint."""
+ hass = self._hass
+ stop = self._stop
+ path = trace_path_get()
+ trace_element = action_trace_append(self._variables, path)
+ self._trace_element = trace_element
+ trace_stack_push(trace_stack_cv, trace_element)
+
+ trace_id = trace_id_get()
+ if trace_id:
+ key = trace_id[0]
+ run_id = trace_id[1]
+ breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS]
+ if key in breakpoints and (
+ (
+ run_id in breakpoints[key]
+ and (
+ path in breakpoints[key][run_id]
+ or NODE_ANY in breakpoints[key][run_id]
+ )
)
- )
- or (
- RUN_ID_ANY in breakpoints[key]
- and (
- path in breakpoints[key][RUN_ID_ANY]
- or NODE_ANY in breakpoints[key][RUN_ID_ANY]
+ or (
+ RUN_ID_ANY in breakpoints[key]
+ and (
+ path in breakpoints[key][RUN_ID_ANY]
+ or NODE_ANY in breakpoints[key][RUN_ID_ANY]
+ )
+ )
+ ):
+ async_dispatcher_send_internal(
+ hass, SCRIPT_BREAKPOINT_HIT, key, run_id, path
)
- )
- ):
- async_dispatcher_send_internal(
- hass, SCRIPT_BREAKPOINT_HIT, key, run_id, path
- )
- done = hass.loop.create_future()
+ done = hass.loop.create_future()
- @callback
- def async_continue_stop(
- command: Literal["continue", "stop"] | None = None,
- ) -> None:
- if command == "stop":
- _set_result_unless_done(stop)
- _set_result_unless_done(done)
+ @callback
+ def async_continue_stop(
+ command: Literal["continue", "stop"] | None = None,
+ ) -> None:
+ if command == "stop":
+ _set_result_unless_done(stop)
+ _set_result_unless_done(done)
- signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id)
- remove_signal1 = async_dispatcher_connect(hass, signal, async_continue_stop)
- remove_signal2 = async_dispatcher_connect(
- hass, SCRIPT_DEBUG_CONTINUE_ALL, async_continue_stop
- )
+ signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id)
+ remove_signal1 = async_dispatcher_connect(
+ hass, signal, async_continue_stop
+ )
+ remove_signal2 = async_dispatcher_connect(
+ hass, SCRIPT_DEBUG_CONTINUE_ALL, async_continue_stop
+ )
- await asyncio.wait([stop, done], return_when=asyncio.FIRST_COMPLETED)
- remove_signal1()
- remove_signal2()
+ await asyncio.wait([stop, done], return_when=asyncio.FIRST_COMPLETED)
+ remove_signal1()
+ remove_signal2()
- try:
- yield trace_element
- except _AbortScript as ex:
- trace_element.set_error(ex.__cause__ or ex)
- raise
- except _ConditionFail:
- # Clear errors which may have been set when evaluating the condition
- trace_element.set_error(None)
- raise
- except _StopScript:
- raise
- except Exception as ex:
- trace_element.set_error(ex)
- raise
- finally:
- trace_stack_pop(trace_stack_cv)
+ return trace_element
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: object,
+ ) -> None:
+ """Finish tracing the action, recording any error."""
+ try:
+ if isinstance(exc_val, _AbortScript):
+ self._trace_element.set_error(exc_val.__cause__ or exc_val)
+ elif isinstance(exc_val, _ConditionFail):
+ # Clear errors which may have been set when evaluating the condition
+ self._trace_element.set_error(None)
+ elif isinstance(exc_val, _StopScript):
+ pass
+ elif isinstance(exc_val, Exception):
+ self._trace_element.set_error(exc_val)
+ finally:
+ trace_stack_pop(trace_stack_cv)
def make_script_schema(
@@ -450,7 +473,12 @@ class _ScriptRun:
_timeout = (
"" if timeout is None else f" (timeout: {timedelta(seconds=timeout)})"
)
- self._log("Executing step %s%s", self._script.last_action, _timeout)
+ self._log(
+ "Executing step %s%s",
+ self._script.last_action,
+ _timeout,
+ level=logging.DEBUG,
+ )
async def async_run(self) -> ScriptRunResult | None:
"""Run script."""
@@ -463,7 +491,11 @@ class _ScriptRun:
response = None
try:
- self._log("Running %s", self._script.running_description)
+ self._log(
+ "Running %s",
+ self._script.running_description,
+ level=logging.INFO if self._script.top_level else logging.DEBUG,
+ )
for self._step, self._action in enumerate(self._script.sequence): # noqa: B020
if self._stop.done():
script_execution_set("cancelled")
@@ -531,6 +563,7 @@ class _ScriptRun:
self._log(
"Skipped disabled step %s",
self._action.get(CONF_ALIAS, action),
+ level=logging.DEBUG,
)
trace_set_result(enabled=False)
return
@@ -777,7 +810,12 @@ class _ScriptRun:
self._log("Error in 'condition' evaluation:\n%s", ex, level=logging.WARNING)
check = False
- self._log("Test condition %s: %s", self._script.last_action, check)
+ self._log(
+ "Test condition %s: %s",
+ self._script.last_action,
+ check,
+ level=logging.DEBUG,
+ )
trace_update_result(result=check)
if not check:
raise _ConditionFail
@@ -822,7 +860,13 @@ class _ScriptRun:
warned_too_many_loops = False
async def async_run_sequence(iteration: int, extra_msg: str = "") -> None:
- self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg)
+ self._log(
+ "Repeating %s: Iteration %i%s",
+ description,
+ iteration,
+ extra_msg,
+ level=logging.DEBUG,
+ )
with trace_path("sequence"):
await self._async_run_script(script)
@@ -1668,6 +1712,15 @@ class Script:
if CONF_ELSE in step:
Script._find_referenced_target(target, referenced, step[CONF_ELSE])
+ elif action == cv.SCRIPT_ACTION_REPEAT:
+ for cond in step[CONF_REPEAT].get(CONF_WHILE, []):
+ referenced |= condition.async_extract_targets(cond, target)
+ for cond in step[CONF_REPEAT].get(CONF_UNTIL, []):
+ referenced |= condition.async_extract_targets(cond, target)
+ Script._find_referenced_target(
+ target, referenced, step[CONF_REPEAT][CONF_SEQUENCE]
+ )
+
elif action == cv.SCRIPT_ACTION_PARALLEL:
for script in step[CONF_PARALLEL]:
Script._find_referenced_target(
@@ -1709,6 +1762,12 @@ class Script:
elif action == cv.SCRIPT_ACTION_DEVICE_AUTOMATION:
referenced.add(step[CONF_DEVICE_ID])
+ elif action == cv.SCRIPT_ACTION_FIRE_EVENT:
+ if (event_data := step.get(CONF_EVENT_DATA)) and isinstance(
+ device_id := event_data.get(ATTR_DEVICE_ID), str
+ ):
+ referenced.add(device_id)
+
elif action == cv.SCRIPT_ACTION_CHOOSE:
for choice in step[CONF_CHOOSE]:
for cond in choice[CONF_CONDITIONS]:
@@ -1724,6 +1783,15 @@ class Script:
if CONF_ELSE in step:
Script._find_referenced_devices(referenced, step[CONF_ELSE])
+ elif action == cv.SCRIPT_ACTION_REPEAT:
+ for cond in step[CONF_REPEAT].get(CONF_WHILE, []):
+ referenced |= condition.async_extract_devices(cond)
+ for cond in step[CONF_REPEAT].get(CONF_UNTIL, []):
+ referenced |= condition.async_extract_devices(cond)
+ Script._find_referenced_devices(
+ referenced, step[CONF_REPEAT][CONF_SEQUENCE]
+ )
+
elif action == cv.SCRIPT_ACTION_PARALLEL:
for script in step[CONF_PARALLEL]:
Script._find_referenced_devices(referenced, script[CONF_SEQUENCE])
@@ -1761,9 +1829,26 @@ class Script:
for trigger in step[CONF_WAIT_FOR_TRIGGER]:
referenced |= set(trigger_helper.async_extract_entities(trigger))
+ elif action == cv.SCRIPT_ACTION_DEVICE_AUTOMATION:
+ # Only extract the entity if it has been resolved to an entity
+ # id during validation; unvalidated configs hold an entity
+ # registry id.
+ if isinstance(
+ entity_id := step.get(ATTR_ENTITY_ID), str
+ ) and valid_entity_id(entity_id):
+ referenced.add(entity_id)
+
elif action == cv.SCRIPT_ACTION_ACTIVATE_SCENE:
referenced.add(step[CONF_SCENE])
+ elif action == cv.SCRIPT_ACTION_FIRE_EVENT:
+ if (
+ (event_data := step.get(CONF_EVENT_DATA))
+ and isinstance(entity_id := event_data.get(ATTR_ENTITY_ID), str)
+ and valid_entity_id(entity_id)
+ ):
+ referenced.add(entity_id)
+
elif action == cv.SCRIPT_ACTION_CHOOSE:
for choice in step[CONF_CHOOSE]:
for cond in choice[CONF_CONDITIONS]:
@@ -1779,6 +1864,15 @@ class Script:
if CONF_ELSE in step:
Script._find_referenced_entities(referenced, step[CONF_ELSE])
+ elif action == cv.SCRIPT_ACTION_REPEAT:
+ for cond in step[CONF_REPEAT].get(CONF_WHILE, []):
+ referenced |= condition.async_extract_entities(cond)
+ for cond in step[CONF_REPEAT].get(CONF_UNTIL, []):
+ referenced |= condition.async_extract_entities(cond)
+ Script._find_referenced_entities(
+ referenced, step[CONF_REPEAT][CONF_SEQUENCE]
+ )
+
elif action == cv.SCRIPT_ACTION_PARALLEL:
for script in step[CONF_PARALLEL]:
Script._find_referenced_entities(referenced, script[CONF_SEQUENCE])
diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py
index 5936aadf2cd0..268cc67e8490 100644
--- a/homeassistant/helpers/selector.py
+++ b/homeassistant/helpers/selector.py
@@ -245,6 +245,26 @@ class DeviceFilterSelectorConfig(TypedDict, total=False):
model_id: str
+ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = (
+ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend(
+ {
+ # Filter on properties of the device the entity belongs to
+ vol.Optional("device"): DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA,
+ }
+ )
+)
+
+
+class EntityWithDeviceFilterSelectorConfig(EntityFilterSelectorConfig, total=False):
+ """Class to represent an entity selector filter config.
+
+ Adds device filtering on top of the shared entity filter, only used by
+ the entity selector.
+ """
+
+ device: DeviceFilterSelectorConfig
+
+
class ActionSelectorConfig(BaseSelectorConfig):
"""Class to represent an action selector config."""
@@ -985,7 +1005,10 @@ class EntitySelectorConfig(
include_entities: list[str]
multiple: bool
reorder: bool
- filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig]
+ filter: (
+ EntityWithDeviceFilterSelectorConfig
+ | list[EntityWithDeviceFilterSelectorConfig]
+ )
@SELECTORS.register("entity")
@@ -1004,7 +1027,7 @@ class EntitySelector(Selector[EntitySelectorConfig]):
vol.Optional("reorder", default=False): cv.boolean,
vol.Optional("filter"): vol.All(
cv.ensure_list,
- [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
+ [ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA],
),
}
),
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index 5a9523099948..c9c56e4f09ab 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -2,13 +2,12 @@
import asyncio
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence
-import dataclasses
from enum import Enum
from functools import cache, partial
import inspect
import logging
from types import ModuleType
-from typing import TYPE_CHECKING, Any, TypedDict, cast, override
+from typing import TYPE_CHECKING, Any, TypedDict, cast
import voluptuous as vol
@@ -60,7 +59,7 @@ from . import (
target as target_helpers,
template,
)
-from .deprecation import deprecated_class, deprecated_function, deprecated_hass_argument
+from .deprecation import deprecated_hass_argument
from .selector import TargetSelector
from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType
@@ -223,33 +222,6 @@ class ServiceParams(TypedDict):
target: dict | None
-@deprecated_class(
- "homeassistant.helpers.target.TargetSelection",
- breaks_in_ha_version="2026.8",
-)
-class ServiceTargetSelector(target_helpers.TargetSelection):
- """Class to hold a target selector for a service."""
-
- def __init__(self, service_call: ServiceCall) -> None:
- """Extract ids from service call data."""
- super().__init__(service_call.data)
-
-
-@deprecated_class(
- "homeassistant.helpers.target.SelectedEntities",
- breaks_in_ha_version="2026.8",
-)
-class SelectedEntities(target_helpers.SelectedEntities):
- """Class to hold the selected entities."""
-
- @override
- def log_missing(
- self, missing_entities: set[str], logger: logging.Logger | None = None
- ) -> None:
- """Log about missing items."""
- super().log_missing(missing_entities, logger or _LOGGER)
-
-
def call_from_config(
hass: HomeAssistant,
config: ConfigType,
@@ -443,21 +415,6 @@ async def async_extract_entity_ids(
return referenced.referenced | referenced.indirectly_referenced
-@deprecated_function(
- "homeassistant.helpers.target.async_extract_referenced_entity_ids",
- breaks_in_ha_version="2026.8",
-)
-def async_extract_referenced_entity_ids(
- hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True
-) -> SelectedEntities:
- """Extract referenced entity IDs from a service call."""
- target_selection = target_helpers.TargetSelection(service_call.data)
- selected = target_helpers.async_extract_referenced_entity_ids(
- hass, target_selection, expand_group
- )
- return SelectedEntities(**dataclasses.asdict(selected))
-
-
@deprecated_hass_argument(breaks_in_ha_version="2026.10")
async def async_extract_config_entry_ids(
service_call: ServiceCall, expand_group: bool = True
diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py
index 87eb7041699b..d34151002f11 100644
--- a/homeassistant/helpers/target.py
+++ b/homeassistant/helpers/target.py
@@ -206,8 +206,19 @@ def async_extract_referenced_entity_ids(
selected.missing_areas.add(area_id)
for device_id in target_selection.device_ids:
- if device_id not in dev_reg.devices:
+ if device_id in dev_reg.devices:
+ selected.referenced_devices.add(device_id)
+ elif split_devices := dev_reg.async_get_devices_for_composite_device_id(
+ device_id
+ ):
+ # A multi config entry composite device id is no longer a device itself;
+ # it resolves to the devices it was split into so actions targeting it
+ # still trickle down. Only the splits are referenced, not the composite id,
+ # so a device-id consumer does not act on the same underlying device twice.
+ selected.referenced_devices.update(device.id for device in split_devices)
+ else:
selected.missing_devices.add(device_id)
+ selected.referenced_devices.add(device_id)
if target_selection.label_ids:
label_reg = lr.async_get(hass)
@@ -234,7 +245,6 @@ def async_extract_referenced_entity_ids(
)
selected.referenced_areas.update(target_selection.area_ids)
- selected.referenced_devices.update(target_selection.device_ids)
if not selected.referenced_areas and not selected.referenced_devices:
return selected
diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py
index fcdba8878ddd..23ff8af3f366 100644
--- a/homeassistant/helpers/template/__init__.py
+++ b/homeassistant/helpers/template/__init__.py
@@ -74,6 +74,7 @@ from .states import (
StateAttrTranslated,
StateTranslated,
TemplateState as TemplateState,
+ TemplateStateBase,
TemplateStateFromEntityId as TemplateStateFromEntityId,
)
@@ -238,8 +239,31 @@ RESULT_WRAPPERS: dict[type, type] = {kls: gen_result_wrapper(kls) for kls in _ty
RESULT_WRAPPERS[tuple] = TupleWrapper
-@lru_cache(maxsize=EVAL_CACHE_SIZE)
def _parse_result(render_result: str) -> Any:
+ """Parse a rendered result.
+
+ Continuously changing numeric results, like sensor values, produce
+ a new string on every render and would always miss the eval cache,
+ paying for a full literal_eval. Convert them directly instead.
+ Anything the fast path cannot convert falls through to the cached
+ path, which handles the edge cases ("", ".", "+") identically.
+ """
+ if _IS_NUMERIC.match(render_result):
+ if "." in render_result:
+ try:
+ return float(render_result)
+ except ValueError:
+ pass
+ else:
+ try:
+ return int(render_result)
+ except ValueError:
+ pass
+ return _cached_parse_result(render_result)
+
+
+@lru_cache(maxsize=EVAL_CACHE_SIZE)
+def _cached_parse_result(render_result: str) -> Any:
"""Parse a result and cache the result."""
# lru_cache does not memoize raised exceptions. The most common template
# results, plain string states such as "on", "off" or "unavailable", are
@@ -817,7 +841,14 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
def is_safe_attribute(self, obj, attr, value):
"""Test if attribute is safe."""
if isinstance(
- obj, (AllStates, DomainStates, TemplateState, LoopContext, AsyncLoopContext)
+ obj,
+ (
+ AllStates,
+ DomainStates,
+ TemplateStateBase,
+ LoopContext,
+ AsyncLoopContext,
+ ),
):
return attr[0] != "_"
diff --git a/homeassistant/helpers/template/extensions/state.py b/homeassistant/helpers/template/extensions/state.py
index 4133789782c3..bb851959a9ca 100644
--- a/homeassistant/helpers/template/extensions/state.py
+++ b/homeassistant/helpers/template/extensions/state.py
@@ -6,11 +6,10 @@ from typing import TYPE_CHECKING, Any
from homeassistant.const import (
ATTR_ENTITY_ID,
- ATTR_LATITUDE,
- ATTR_LONGITUDE,
ATTR_PERSONS,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
UnitOfLength,
)
from homeassistant.core import State, valid_entity_id
@@ -228,8 +227,8 @@ class StateExtension(BaseTemplateExtension):
)
return None
- latitude = point_state.attributes[ATTR_LATITUDE]
- longitude = point_state.attributes[ATTR_LONGITUDE]
+ latitude = point_state.attributes[EntityStateAttribute.LATITUDE]
+ longitude = point_state.attributes[EntityStateAttribute.LONGITUDE]
entities = args[1]
@@ -308,8 +307,8 @@ class StateExtension(BaseTemplateExtension):
)
return None
- latitude = point_state.attributes[ATTR_LATITUDE]
- longitude = point_state.attributes[ATTR_LONGITUDE]
+ latitude = point_state.attributes[EntityStateAttribute.LATITUDE]
+ longitude = point_state.attributes[EntityStateAttribute.LONGITUDE]
locations.append((latitude, longitude))
diff --git a/homeassistant/helpers/template/states.py b/homeassistant/helpers/template/states.py
index af8abdf608a7..4563f8d2a4d0 100644
--- a/homeassistant/helpers/template/states.py
+++ b/homeassistant/helpers/template/states.py
@@ -9,7 +9,7 @@ from typing import Any, override
from lru import LRU
from propcache.api import under_cached_property
-from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN
+from homeassistant.const import STATE_UNKNOWN, EntityStateAttribute
from homeassistant.core import (
Context,
HomeAssistant,
@@ -182,7 +182,7 @@ class StateTranslated:
state_value = state.state
domain = state.domain
- device_class = state.attributes.get("device_class")
+ device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
entry = er.async_get(self._hass).async_get(entity_id)
platform = None if entry is None else entry.platform
translation_key = None if entry is None else entry.translation_key
@@ -219,7 +219,7 @@ class StateAttrTranslated:
return attr_value
domain = state.domain
- device_class = state.attributes.get("device_class")
+ device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS)
entry = er.async_get(self._hass).async_get(entity_id)
platform = None if entry is None else entry.platform
translation_key = None if entry is None else entry.translation_key
@@ -413,7 +413,9 @@ class TemplateStateBase(State):
state = async_rounded_state(self._hass, self._entity_id, self._state)
else:
state = self._state.state
- if with_unit and (unit := self._state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
+ if with_unit and (
+ unit := self._state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ ):
return f"{state} {unit}"
return state
diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py
index 2c2110af8dfc..84836af8c9d3 100644
--- a/homeassistant/helpers/trace.py
+++ b/homeassistant/helpers/trace.py
@@ -305,17 +305,27 @@ def script_execution_get() -> str | None:
return data.script_execution
-@contextmanager
-def trace_path(suffix: str | list[str]) -> Generator[None]:
+class trace_path:
"""Go deeper in the config tree.
- Can not be used as a decorator on couroutine functions.
+ Can not be used as a decorator.
"""
- count = trace_path_push(suffix)
- try:
- yield
- finally:
- trace_path_pop(count)
+
+ __slots__ = ("_count", "_suffix")
+
+ _count: int
+
+ def __init__(self, suffix: str | list[str]) -> None:
+ """Store the path suffix to push on enter."""
+ self._suffix = suffix
+
+ def __enter__(self) -> None:
+ """Go deeper in the config tree."""
+ self._count = trace_path_push(self._suffix)
+
+ def __exit__(self, *exc: object) -> None:
+ """Go back up in the config tree."""
+ trace_path_pop(self._count)
def async_trace_path[*_Ts](
diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py
index a541242de992..f07a0a8bf5d7 100644
--- a/homeassistant/helpers/trigger.py
+++ b/homeassistant/helpers/trigger.py
@@ -26,8 +26,8 @@ import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
- ATTR_UNIT_OF_MEASUREMENT,
CONF_ALIAS,
+ CONF_AT,
CONF_DEVICE_ID,
CONF_ENABLED,
CONF_ENTITY_ID,
@@ -42,6 +42,7 @@ from homeassistant.const import (
CONF_ZONE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ EntityStateAttribute,
)
from homeassistant.core import (
CALLBACK_TYPE,
@@ -856,7 +857,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
entity_id=threshold.entity,
)
return None
- unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if not self._is_valid_unit(unit):
# Entity unit does not match the expected unit
report_not_triggered(
@@ -882,7 +883,9 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
- if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
+ if not self._is_valid_unit(
+ state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
+ ):
return None
raw_value = state.state
else:
@@ -907,7 +910,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
- unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if not self._is_valid_unit(unit):
report_not_triggered(
"entity_unit_not_supported",
@@ -984,7 +987,7 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
def _get_entity_unit(self, state: State) -> str | None:
"""Get the unit of an entity from its state."""
- return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ return state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
@override
def _report_tracked_value_problem(
@@ -1050,7 +1053,7 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
)
return None
- unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
try:
return self._unit_converter.convert(value, unit, self._base_unit)
except HomeAssistantError:
@@ -2053,6 +2056,26 @@ def async_extract_entities(trigger_conf: dict) -> list[str]:
if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"):
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
+ if trigger_conf[CONF_PLATFORM] == "time":
+ # Each at time can be a time, an entity id, an entity id with
+ # an offset, or a template.
+ entity_ids: list[str] = []
+ for at_time in trigger_conf[CONF_AT]:
+ if isinstance(at_time, str) and valid_entity_id(at_time):
+ entity_ids.append(at_time)
+ elif isinstance(at_time, dict) and CONF_ENTITY_ID in at_time:
+ entity_ids.append(at_time[CONF_ENTITY_ID])
+ return entity_ids
+
+ if trigger_conf[CONF_PLATFORM] == "device":
+ # Only extract the entity if it has been resolved to an entity id
+ # during validation; unvalidated configs hold an entity registry id.
+ if isinstance(
+ entity_id := trigger_conf.get(CONF_ENTITY_ID), str
+ ) and valid_entity_id(entity_id):
+ return [entity_id]
+ return []
+
if trigger_conf[CONF_PLATFORM] == "calendar":
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py
index e5ad9964ea27..9515fd43f85d 100644
--- a/homeassistant/helpers/trigger_template_entity.py
+++ b/homeassistant/helpers/trigger_template_entity.py
@@ -18,14 +18,12 @@ from homeassistant.components.sensor.helpers import ( # pylint: disable=home-as
async_parse_date_datetime,
)
from homeassistant.const import (
- ATTR_ENTITY_PICTURE,
- ATTR_FRIENDLY_NAME,
- ATTR_ICON,
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_NAME,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
+ EntityStateAttribute,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
@@ -48,9 +46,9 @@ CONF_ATTRIBUTES = "attributes"
CONF_PICTURE = "picture"
CONF_TO_ATTRIBUTE = {
- CONF_ICON: ATTR_ICON,
- CONF_NAME: ATTR_FRIENDLY_NAME,
- CONF_PICTURE: ATTR_ENTITY_PICTURE,
+ CONF_ICON: EntityStateAttribute.ICON,
+ CONF_NAME: EntityStateAttribute.FRIENDLY_NAME,
+ CONF_PICTURE: EntityStateAttribute.ENTITY_PICTURE,
}
TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema(
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index 78ab8b7accf6..4efe8fd385d3 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -101,8 +101,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
frame.report_usage(
"relies on ContextVar, but should pass the config entry explicitly.",
core_behavior=frame.ReportBehavior.ERROR,
+ core_integration_behavior=frame.ReportBehavior.ERROR,
custom_integration_behavior=frame.ReportBehavior.IGNORE,
- breaks_in_ha_version="2026.8",
)
self.config_entry = config_entries.current_entry.get()
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 37e9c989b35b..176976e770e5 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -13,7 +13,7 @@ aiozoneinfo==0.2.3
annotatedyaml==1.0.2
astral==2.2
async-interrupt==1.2.2
-async-upnp-client==0.46.2
+async-upnp-client==0.47.0
atomicwrites-homeassistant==1.4.1
attrs==26.1.0
audioop-lts==0.2.2
@@ -35,22 +35,22 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
-habluetooth==6.26.2
+habluetooth==6.26.5
hass-nabucasa==2.2.0
hassil==3.8.0
home-assistant-bluetooth==2.0.0
-home-assistant-frontend==20260624.4
+home-assistant-frontend==20260624.6
home-assistant-intents==2026.6.24
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
lru-dict==1.4.1
mutagen==1.48.1
-openai==2.21.0
+openai==2.45.0
orjson==3.11.9
packaging>=23.1
paho-mqtt==2.1.0
-Pillow==12.2.0
+Pillow==12.3.0
propcache==0.5.2
psutil-home-assistant==0.0.1
PyJWT==2.12.1
@@ -70,7 +70,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
-uv==0.11.25
+uv==0.11.28
voluptuous-openapi==0.4.1
voluptuous-serialize==2.7.0
voluptuous==0.15.2
@@ -256,3 +256,7 @@ azure-kusto-data==4.5.1
azure-kusto-ingest==4.5.1
coloredlogs==15.0.1
setuptools==81.0.0
+
+# Pin cffi to 2.0.0 to avoid version mismatch with the pre-baked _cffi_backend in the base image.
+# https://github.com/home-assistant/core/issues/175832
+cffi==2.0.0
diff --git a/homeassistant/runner.py b/homeassistant/runner.py
index 61936a49c0d3..0150f93fe15b 100644
--- a/homeassistant/runner.py
+++ b/homeassistant/runner.py
@@ -18,7 +18,7 @@ import threading
import time
from time import monotonic
import traceback
-from typing import Any
+from typing import Any, override
import packaging.tags
@@ -173,7 +173,7 @@ class RuntimeConfig:
safe_mode: bool = False
-class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[name-defined,misc]
+class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
"""Event loop policy for Home Assistant."""
def __init__(self, debug: bool) -> None:
@@ -184,8 +184,9 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[name-
@property
def loop_name(self) -> str:
"""Return name of the loop."""
- return self._loop_factory.__name__ # type: ignore[no-any-return]
+ return self._loop_factory.__name__ # type: ignore[attr-defined,no-any-return]
+ @override
def new_event_loop(self) -> asyncio.AbstractEventLoop:
"""Get the event loop."""
loop: asyncio.AbstractEventLoop = super().new_event_loop()
diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py
index 8ca2ef7fef11..173d792ba6e3 100644
--- a/homeassistant/scripts/auth.py
+++ b/homeassistant/scripts/auth.py
@@ -11,6 +11,7 @@ from homeassistant import runner
from homeassistant.auth import auth_manager_from_config
from homeassistant.auth.providers import homeassistant as hass_auth
from homeassistant.config import get_default_config_dir
+from homeassistant.config_entries import ConfigEntries
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -55,6 +56,9 @@ def run(args: Sequence[str] | None) -> None:
async def run_command(args: argparse.Namespace) -> None:
"""Run the command."""
hass = HomeAssistant(os.path.join(os.getcwd(), args.config))
+ hass.config_entries = ConfigEntries(hass, {})
+ # The device registry migration waits for the config entries to load
+ await hass.config_entries.async_initialize()
dr.async_setup(hass)
await asyncio.gather(dr.async_load(hass), er.async_load(hass))
hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], [])
diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py
index 525201f80b75..635bbca59614 100644
--- a/homeassistant/scripts/check_config.py
+++ b/homeassistant/scripts/check_config.py
@@ -300,6 +300,8 @@ async def async_check_config(config_dir):
hass = core.HomeAssistant(config_dir)
loader.async_setup(hass)
hass.config_entries = ConfigEntries(hass, {})
+ # The device registry migration waits for the config entries to load
+ await hass.config_entries.async_initialize()
dr.async_setup(hass)
await ar.async_load(hass)
await dr.async_load(hass)
diff --git a/mypy.ini b/mypy.ini
index 519bd1cb4c6b..e9ddf4e4135d 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -2037,6 +2037,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.gatus.*]
+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.generic_hygrostat.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2177,6 +2187,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.google_health.*]
+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.google_photos.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2297,6 +2317,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.harman_luxury.*]
+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.hdfury.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3107,6 +3137,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.led_infrared.*]
+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.lektrico.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3217,6 +3257,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.litellm.*]
+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.litterrobot.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3227,6 +3277,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.llama_cpp.*]
+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.local_ip.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3687,6 +3747,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.neopool.*]
+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.nest.*]
check_untyped_defs = true
disallow_incomplete_defs = true
diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md
index 11af4d2b5af0..01d6af257e39 100644
--- a/pylint/plugins/README.md
+++ b/pylint/plugins/README.md
@@ -132,6 +132,7 @@ Every check has a code following the
| `W7415` | [`home-assistant-sequential-executor-jobs`](#w7415-home-assistant-sequential-executor-jobs) | Sequential `async_add_executor_job` calls should be grouped |
| `W7416` | [`home-assistant-missing-has-entity-name`](#w7416-home-assistant-missing-has-entity-name) | Entity class should set `_attr_has_entity_name = True` |
| `W7429` | [`home-assistant-unnecessary-format-mac`](#w7429-home-assistant-unnecessary-format-mac) | `format_mac()` is unnecessary with `CONNECTION_NETWORK_MAC` |
+| `W7430` | [`home-assistant-serial-port-selector-usb-dependency`](#w7430-home-assistant-serial-port-selector-usb-dependency) | Config flow using `SerialPortSelector` must declare `usb` in `dependencies` |
## `home_assistant_logger` checker
@@ -849,3 +850,17 @@ Tuples used for direct comparison against `device.connections` (e.g.
the `in` operator, set intersection) are not flagged because those
comparisons bypass the device registry normalization and genuinely
need `format_mac()` to match the stored normalized format.
+
+
+## `home_assistant_serial_port_selector_usb_dependency` checker
+
+Detects config flows using `SerialPortSelector` whose `manifest.json` does
+not declare `usb` as a hard dependency.
+
+### `W7430`: `home-assistant-serial-port-selector-usb-dependency`
+
+`SerialPortSelector` populates its port list via the `usb/list_serial_ports`
+websocket command, which is only registered when the `usb` integration is set
+up. The selector therefore requires `usb` as a hard dependency
+(`"dependencies": ["usb"]`); `after_dependencies` is not sufficient because it
+does not force `usb` to be set up.
diff --git a/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py b/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py
new file mode 100644
index 000000000000..cbdf75cde488
--- /dev/null
+++ b/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py
@@ -0,0 +1,81 @@
+"""Checker for the usb dependency when a config flow uses SerialPortSelector.
+
+``SerialPortSelector`` populates its port list via the
+``usb/list_serial_ports`` websocket command, which is only registered when the
+``usb`` integration is set up. Integrations using the selector must therefore
+declare ``usb`` as a hard dependency in ``manifest.json`` so it is guaranteed
+to be set up; ``after_dependencies`` is not sufficient because it does not
+force ``usb`` to be set up.
+"""
+
+from astroid import nodes
+from pylint.checkers import BaseChecker
+from pylint.lint import PyLinter
+
+from pylint_home_assistant.const import Module
+from pylint_home_assistant.helpers.integration import read_manifest
+from pylint_home_assistant.helpers.module_info import parse_module
+
+
+class HassEnforceSerialPortSelectorUsbChecker(BaseChecker):
+ """Checker for the usb dependency when using SerialPortSelector."""
+
+ name = "home_assistant_serial_port_selector_usb_dependency"
+ priority = -1
+ msgs = {
+ "W7430": (
+ "Config flow uses SerialPortSelector but the integration does not "
+ "declare 'usb' in 'dependencies' in manifest.json",
+ "home-assistant-serial-port-selector-usb-dependency",
+ "SerialPortSelector populates its port list via the "
+ "'usb/list_serial_ports' websocket command, which is only "
+ "registered when the 'usb' integration is set up. The selector "
+ "therefore requires 'usb' as a hard dependency; "
+ "'after_dependencies' is not sufficient because it does not force "
+ "'usb' to be set up.",
+ ),
+ }
+ options = ()
+
+ def __init__(self, linter: PyLinter) -> None:
+ """Initialize the checker."""
+ super().__init__(linter)
+ self._reported_modules: set[str] = set()
+
+ def visit_call(self, node: nodes.Call) -> None:
+ """Check that SerialPortSelector usage declares the usb dependency."""
+ func = node.func
+ if isinstance(func, nodes.Attribute):
+ name = func.attrname
+ elif isinstance(func, nodes.Name):
+ name = func.name
+ else:
+ return
+ if name != "SerialPortSelector":
+ return
+
+ parsed = parse_module(node.root().name)
+ if parsed is None or parsed.module != Module.CONFIG_FLOW:
+ return
+
+ module_name = node.root().name
+ if module_name in self._reported_modules:
+ return
+
+ manifest = read_manifest(node.root())
+ if manifest is None:
+ return
+
+ if "usb" in manifest.get("dependencies", []):
+ return
+
+ self._reported_modules.add(module_name)
+ self.add_message(
+ "home-assistant-serial-port-selector-usb-dependency",
+ node=node,
+ )
+
+
+def register(linter: PyLinter) -> None:
+ """Register the checker."""
+ linter.register_checker(HassEnforceSerialPortSelectorUsbChecker(linter))
diff --git a/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py b/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py
index 3d4f0efea9e3..0c29cf5ef95a 100644
--- a/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py
+++ b/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py
@@ -4,6 +4,10 @@ New integrations should store per-entry data on ``entry.runtime_data`` (typed
via a ``type`` alias) rather than the legacy ``hass.data[DOMAIN][entry.entry_id]``
dictionary pattern. The ``runtime_data`` approach is type-safe, automatically
cleaned up on unload, and is the current Home Assistant core standard.
+
+Both the subscript form (``hass.data[DOMAIN]``) and the equivalent method-call
+forms (``hass.data.setdefault(DOMAIN, ...)`` and ``hass.data.get(DOMAIN)``) are
+flagged.
"""
from astroid import nodes
@@ -54,22 +58,7 @@ class HassEnforceRuntimeDataChecker(BaseChecker):
if not _is_hass_data_domain_access(node):
return
- parsed = parse_module(node.root().name)
- if parsed is None:
- return
-
- current_module = parsed.module or ""
- if current_module in _SKIP_MODULES:
- return
-
- # Only flag integrations that have a config flow (and thus can use
- # entry.runtime_data). YAML-only integrations legitimately need
- # hass.data[DOMAIN].
- if not has_config_flow(parsed.domain, node.root()):
- return
-
- func = enclosing_function(node)
- if func and func.name in _SKIP_FUNCTIONS:
+ if not self._should_flag(node):
return
# Don't flag deletion: del hass.data[DOMAIN] or hass.data[DOMAIN].pop(...)
@@ -81,25 +70,78 @@ class HassEnforceRuntimeDataChecker(BaseChecker):
self.add_message("home-assistant-use-runtime-data", node=node)
+ def visit_call(self, node: nodes.Call) -> None:
+ """Check for hass.data.setdefault(DOMAIN, ...) and hass.data.get(DOMAIN)."""
+ if not _is_hass_data_domain_call(node):
+ return
+
+ if not self._should_flag(node):
+ return
+
+ self.add_message("home-assistant-use-runtime-data", node=node)
+
+ def _should_flag(self, node: nodes.NodeNG) -> bool:
+ """Return True if node is in an integration that should use runtime_data."""
+ parsed = parse_module(node.root().name)
+ if parsed is None:
+ return False
+
+ current_module = parsed.module or ""
+ if current_module in _SKIP_MODULES:
+ return False
+
+ # Only flag integrations that have a config flow (and thus can use
+ # entry.runtime_data). YAML-only integrations legitimately need
+ # hass.data[DOMAIN].
+ if not has_config_flow(parsed.domain, node.root()):
+ return False
+
+ func = enclosing_function(node)
+ return not (func and func.name in _SKIP_FUNCTIONS)
+
+
+def _is_hass_data(node: nodes.NodeNG) -> bool:
+ """Return True if node is hass.data or self.hass.data."""
+ match node:
+ case nodes.Attribute(
+ expr=(
+ nodes.Name(name="hass")
+ | nodes.Attribute(expr=nodes.Name(name="self"), attrname="hass")
+ ),
+ attrname="data",
+ ):
+ return True
+ case _:
+ return False
+
def _is_hass_data_domain_access(node: nodes.Subscript) -> bool:
"""Return True if node is hass.data[DOMAIN] or self.hass.data[DOMAIN]."""
match node:
- case nodes.Subscript(
- value=nodes.Attribute(
- expr=(
- nodes.Name(name="hass")
- | nodes.Attribute(expr=nodes.Name(name="self"), attrname="hass")
- ),
- attrname="data",
- ),
- slice=nodes.Name(name="DOMAIN"),
+ case nodes.Subscript(value=value, slice=nodes.Name(name="DOMAIN")) if (
+ _is_hass_data(value)
):
return True
case _:
return False
+def _is_hass_data_domain_call(node: nodes.Call) -> bool:
+ """Return True for hass.data.setdefault(DOMAIN, ...) or hass.data.get(DOMAIN).
+
+ These read/write DOMAIN data just like the subscript form. Deletion helpers
+ such as ``hass.data.pop(DOMAIN)`` are intentionally not matched.
+ """
+ match node:
+ case nodes.Call(
+ func=nodes.Attribute(expr=value, attrname="setdefault" | "get"),
+ args=[nodes.Name(name="DOMAIN"), *_],
+ ) if _is_hass_data(value):
+ return True
+ case _:
+ return False
+
+
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(HassEnforceRuntimeDataChecker(linter))
diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py
index f9e4b3d4ebec..bee392ebe525 100644
--- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py
+++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
-FRONTEND_VERSION: Final[str] = "20260624.4"
+FRONTEND_VERSION: Final[str] = "20260624.6"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
diff --git a/pyproject.toml b/pyproject.toml
index 112eb25df626..9a4dfa5693ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,7 +58,7 @@ dependencies = [
"PyJWT==2.12.1",
# PyJWT has loose dependency. We want the latest one.
"cryptography==48.0.1",
- "Pillow==12.2.0",
+ "Pillow==12.3.0",
"propcache==0.5.2",
"pyOpenSSL==26.2.0",
"orjson==3.11.9",
@@ -74,7 +74,7 @@ dependencies = [
"typing-extensions>=4.15.0,<5.0",
"ulid-transform==2.2.9",
"urllib3>=2.0",
- "uv==0.11.25",
+ "uv==0.11.28",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.4.1",
@@ -648,7 +648,7 @@ exclude_lines = [
]
[tool.ruff]
-required-version = ">=0.15.20"
+required-version = ">=0.15.21"
[tool.ruff.lint]
select = [
diff --git a/requirements.txt b/requirements.txt
index 4cc45c51c995..ae925cb00902 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -30,13 +30,13 @@ home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.6.24
httpx==0.28.1
ifaddr==0.2.0
-infrared-protocols==6.3.1
+infrared-protocols==7.5.0
Jinja2==3.1.6
lru-dict==1.4.1
mutagen==1.48.1
orjson==3.11.9
packaging>=23.1
-Pillow==12.2.0
+Pillow==12.3.0
propcache==0.5.2
psutil-home-assistant==0.0.1
PyJWT==2.12.1
@@ -55,7 +55,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
-uv==0.11.25
+uv==0.11.28
voluptuous-openapi==0.4.1
voluptuous-serialize==2.7.0
voluptuous==0.15.2
diff --git a/requirements_all.txt b/requirements_all.txt
index 07c3d7b2d673..ceb7f8943a28 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -7,7 +7,7 @@
AEMET-OpenData==0.6.4
# homeassistant.components.honeywell
-AIOSomecomfort==0.0.35
+AIOSomecomfort==0.0.38
# homeassistant.components.adax
Adax-local==0.3.0
@@ -38,7 +38,7 @@ PSNAWP==3.0.3
# homeassistant.components.qrcode
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
-Pillow==12.2.0
+Pillow==12.3.0
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -99,7 +99,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.3
# homeassistant.components.vicare
-PyViCare==2.60.2
+PyViCare==2.61.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -181,7 +181,7 @@ aio-ownet==0.0.5
aio-wattwaechter==1.0.0
# homeassistant.components.acaia
-aioacaia==0.1.18
+aioacaia==0.2.0
# homeassistant.components.airq
aioairq==0.4.8
@@ -193,7 +193,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
-aioamazondevices==14.1.8
+aioamazondevices==14.2.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -206,7 +206,7 @@ aioapcaccess==1.0.0
aioaquacell==1.0.0
# homeassistant.components.vistapool
-aioaquarite==0.5.1
+aioaquarite==0.6.1
# homeassistant.components.aseko_pool_live
aioaseko==1.0.0
@@ -260,7 +260,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==45.5.2
+aioesphomeapi==45.6.0
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -281,6 +281,9 @@ aiogithubapi==26.0.0
# homeassistant.components.guardian
aioguardian==2026.01.1
+# homeassistant.components.harman_luxury
+aioharmanluxury==0.2.3
+
# homeassistant.components.harmony
aioharmony==1.0.8
@@ -303,11 +306,14 @@ aiohue==4.8.1
aioimaplib==2.0.1
# homeassistant.components.immich
-aioimmich==0.15.1
+aioimmich==0.16.1
# homeassistant.components.apache_kafka
aiokafka==0.10.0
+# homeassistant.components.karakeep
+aiokarakeep==0.3.0
+
# homeassistant.components.kef
aiokef==0.2.16
@@ -336,7 +342,7 @@ aiolyric==2.1.1
aiomealie==1.2.4
# homeassistant.components.melcloud_home
-aiomelcloudhome==0.1.9
+aiomelcloudhome==0.2.1
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -401,7 +407,7 @@ aiorecollect==2023.09.0
aioridwell==2025.09.0
# homeassistant.components.ruckus_unleashed
-aioruckus==0.42
+aioruckus==0.46.3
# homeassistant.components.russound_rio
# homeassistant.components.russound_rnet
@@ -411,7 +417,7 @@ aiorussound==5.0.1
aioruuvigateway==0.1.0
# homeassistant.components.shelly
-aioshelly==13.26.2
+aioshelly==13.27.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -566,7 +572,7 @@ asusrouter==1.21.3
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
-async-upnp-client==0.46.2
+async-upnp-client==0.47.0
# homeassistant.components.arve
asyncarve==0.1.1
@@ -606,7 +612,7 @@ av==17.0.1
avea==1.8.0
# homeassistant.components.axis
-axis==72
+axis==74
# homeassistant.components.fujitsu_fglair
ayla-iot-unofficial==1.4.7
@@ -648,7 +654,7 @@ beautifulsoup4==4.13.3
bizkaibus==0.1.1
# homeassistant.components.esphome
-bleak-esphome==3.9.4
+bleak-esphome==3.9.7
# homeassistant.components.bluetooth
bleak-retry-connector==4.6.1
@@ -823,7 +829,7 @@ deluge-client==1.10.2
demetriek==1.3.0
# homeassistant.components.denon_rs232
-denon-rs232==4.1.0
+denon-rs232==4.2.1
# homeassistant.components.denonavr
denonavr==1.3.3
@@ -856,7 +862,7 @@ dremel3dpy==2.1.1
dropmqttapi==1.0.3
# homeassistant.components.dsmr
-dsmr-parser==1.9.0
+dsmr-parser==1.11.1
# homeassistant.components.dwd_weather_warnings
dwdwfsapi==1.0.7
@@ -919,7 +925,7 @@ emoji==2.8.0
emulated-roku==0.3.0
# homeassistant.components.energieleser
-energieleser==0.1.4
+energieleser==0.1.5
# homeassistant.components.huisbaasje
energyflip-client==0.2.2
@@ -1071,6 +1077,9 @@ gardena-bluetooth==2.8.1
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
+# homeassistant.components.gatus
+gatus-api==1.0.3
+
# homeassistant.components.google
gcal-sync==8.0.0
@@ -1133,6 +1142,9 @@ google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
google-genai==1.59.0
+# homeassistant.components.google_health
+google-health-api==0.5.1
+
# homeassistant.components.google_travel_time
google-maps-routing==0.6.15
@@ -1186,7 +1198,7 @@ growattServer==2.1.0
gspread==5.5.0
# homeassistant.components.guntamatic
-guntamatic==1.9.0
+guntamatic==1.9.2
# homeassistant.components.profiler
guppy3==3.1.7
@@ -1198,10 +1210,10 @@ h2==4.3.0
ha-ffmpeg==3.2.2
# homeassistant.components.iotawatt
-ha-iotawattpy==0.1.2
+ha-iotawattpy==0.2.1
# homeassistant.components.philips_js
-ha-philipsjs==3.2.4
+ha-philipsjs==3.2.5
# homeassistant.components.homeassistant_hardware
ha-silabs-firmware-client==0.3.0
@@ -1213,11 +1225,14 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
-habluetooth==6.26.2
+habluetooth==6.26.5
# homeassistant.components.hanna
hanna-cloud==0.0.7
+# homeassistant.components.harbor
+harbor-python==1.2.1
+
# homeassistant.components.cloud
hass-nabucasa==2.2.0
@@ -1263,10 +1278,10 @@ hole==0.9.2
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.99
+holidays==0.100
# homeassistant.components.frontend
-home-assistant-frontend==20260624.4
+home-assistant-frontend==20260624.6
# homeassistant.components.conversation
home-assistant-intents==2026.6.24
@@ -1344,7 +1359,7 @@ ihcsdk==2.8.12
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
-imgw_pib==2.4.0
+imgw_pib==2.4.3
# homeassistant.components.incomfort
incomfort-client==0.7.0
@@ -1359,7 +1374,7 @@ influxdb-client==1.50.0
influxdb==5.3.2
# homeassistant.components.infrared
-infrared-protocols==6.3.1
+infrared-protocols==7.5.0
# homeassistant.components.inkbird
inkbird-ble==1.4.4
@@ -1368,7 +1383,7 @@ inkbird-ble==1.4.4
insteon-frontend-home-assistant==0.6.2
# homeassistant.components.intellifire
-intellifire4py==4.4.0
+intellifire4py==4.5.0
# homeassistant.components.iometer
iometer==1.0.2
@@ -1426,7 +1441,7 @@ knocki==0.4.2
knx-frontend==2026.6.23.203726
# homeassistant.components.knx
-knx-telegram-store[sqlite]==0.3.2
+knx-telegram-store[sqlite,postgres]==0.10.1
# homeassistant.components.kraken
krakenex==2.2.2
@@ -1518,6 +1533,9 @@ lw12==0.9.2
# homeassistant.components.scrape
lxml==6.1.1
+# homeassistant.components.lyngdorf
+lyngdorf==1.3.3
+
# homeassistant.components.matrix
matrix-nio==0.25.2
@@ -1580,7 +1598,7 @@ millheater==0.14.1
minio==7.1.12
# homeassistant.components.mitsubishi_comfort
-mitsubishi-comfort==0.3.2
+mitsubishi-comfort==0.5.0
# homeassistant.components.moat
moat-ble==0.1.1
@@ -1591,6 +1609,9 @@ moehlenhoff-alpha2==1.4.0
# homeassistant.components.route_b_smart_meter
momonga==0.3.0
+# homeassistant.components.monarch_money
+monarchmoneycommunity==1.5.1
+
# homeassistant.components.monzo
monzopy==1.5.1
@@ -1613,7 +1634,7 @@ mozart-api==6.2.0.44.0
mullvad-api==1.0.0
# homeassistant.components.music_assistant
-music-assistant-client==1.3.6
+music-assistant-client==1.4.3
# homeassistant.components.tts
mutagen==1.48.1
@@ -1624,9 +1645,6 @@ mutesync==0.0.1
# homeassistant.components.mvglive
mvg==1.4.0
-# homeassistant.components.permobil
-mypermobil==0.1.8
-
# homeassistant.components.myuplink
myuplink==0.7.0
@@ -1636,6 +1654,9 @@ nad-receiver==0.3.0
# homeassistant.components.keenetic_ndms2
ndms2-client==0.1.2
+# homeassistant.components.neopool
+neopool-modbus==3.6.0
+
# homeassistant.components.ness_alarm
nessclient==1.3.1
@@ -1740,7 +1761,7 @@ ondilo==0.5.0
onedrive-personal-sdk==0.1.7
# homeassistant.components.onvif
-onvif-zeep-async==4.2.0
+onvif-zeep-async==4.2.1
# homeassistant.components.onvif
onvif_parsers==2.3.0
@@ -1752,10 +1773,12 @@ open-garage==0.2.0
open-meteo==0.3.2
# homeassistant.components.cloud
+# homeassistant.components.litellm
+# homeassistant.components.llama_cpp
# homeassistant.components.open_router
# homeassistant.components.openai_conversation
# homeassistant.components.ovhcloud_ai_endpoints
-openai==2.21.0
+openai==2.45.0
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -1782,7 +1805,7 @@ openwrt-ubus-rpc==0.0.3
opower==0.18.6
# homeassistant.components.oralb
-oralb-ble==1.1.0
+oralb-ble==1.1.1
# homeassistant.components.oru
oru==0.1.11
@@ -1942,7 +1965,7 @@ py-schluter==0.1.7
py-sucks==0.9.11
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.10.2
+py-synologydsm-api==2.10.3
# homeassistant.components.unifi_access
py-unifi-access==1.3.0
@@ -1954,7 +1977,7 @@ pyAtome==0.1.2
pyCEC==0.5.2
# homeassistant.components.control4
-pyControl4==1.5.0
+pyControl4==2.0.2
# homeassistant.components.duotecno
pyDuotecno==2024.10.1
@@ -1969,7 +1992,7 @@ pyEmby==1.10
pyHik==0.4.3
# homeassistant.components.homee
-pyHomee==1.4.1
+pyHomee==1.4.2
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
@@ -1987,7 +2010,7 @@ pyW215==0.8.0
pyW800rf32==0.4
# homeassistant.components.ccm15
-py_ccm15==1.0.0
+py_ccm15==1.1.2
# homeassistant.components.html5
py_vapid==1.9.4
@@ -2002,10 +2025,10 @@ pyaehw4a1==0.3.9
pyaftership==21.11.0
# homeassistant.components.airnow
-pyairnow==1.3.1
+pyairnow==1.4.0
# homeassistant.components.airobot
-pyairobotrest==0.3.0
+pyairobotrest==0.4.0
# homeassistant.components.airvisual
# homeassistant.components.airvisual_pro
@@ -2050,6 +2073,9 @@ pyblu==2.0.8
# homeassistant.components.neato
pybotvac==0.0.29
+# homeassistant.components.fuelprices_dk
+pybraendstofpriser==2.2.0
+
# homeassistant.components.braviatv
pybravia==0.4.1
@@ -2087,7 +2113,7 @@ pycsspeechtts==1.0.8
pycync==0.5.0
# homeassistant.components.daikin
-pydaikin==2.18.1
+pydaikin==2.18.2
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@@ -2232,7 +2258,7 @@ pyialarm==2.2.0
pyicloud==2.6.5
# homeassistant.components.imou
-pyimouapi==1.2.8
+pyimouapi==1.3.0
# homeassistant.components.insteon
pyinsteon==1.6.4
@@ -2301,7 +2327,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
-pylamarzocco==2.4.1
+pylamarzocco==2.4.2
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2325,10 +2351,10 @@ pylitejet==0.6.3
pylitterbot==2025.5.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.28.0
+pylutron-caseta==0.29.0
# homeassistant.components.lutron
-pylutron==0.4.1
+pylutron==0.4.2
# homeassistant.components.mailgun
pymailgunner==1.4
@@ -2432,7 +2458,7 @@ pyotgw==2.2.3
pyotp==2.9.0
# homeassistant.components.overkiz
-pyoverkiz[nexity]==2.0.3
+pyoverkiz[nexity]==2.1.0
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2459,7 +2485,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
-pyportainer==1.0.38
+pyportainer==1.0.42
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2486,7 +2512,7 @@ pyqwikswitch==0.93
pyrail==0.4.1
# homeassistant.components.rainbird
-pyrainbird==6.3.1
+pyrainbird==6.5.0
# homeassistant.components.playstation_network
pyrate-limiter==4.4.0
@@ -2516,11 +2542,14 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.saunum
-pysaunum==0.6.0
+pysaunum==0.7.0
# homeassistant.components.schlage
pyschlage==2025.9.0
+# homeassistant.components.scorpiontrack
+pyscorpiontrack==0.1.1
+
# homeassistant.components.sensibo
pysensibo==1.2.1
@@ -2564,7 +2593,7 @@ pysmhi==2.0.0
pysml==0.1.8
# homeassistant.components.smlight
-pysmlight==0.5.2
+pysmlight==0.5.3
# homeassistant.components.snmp
pysnmp==7.1.27
@@ -2585,7 +2614,7 @@ pyspeex-noise==1.0.2
pysqueezebox==0.14.0
# homeassistant.components.stiebel_eltron
-pystiebeleltron==0.2.5
+pystiebeleltron==0.5.0
# homeassistant.components.suez_water
pysuezV2==2.0.7
@@ -2615,7 +2644,7 @@ python-awair==0.2.5
python-blockchain-api==0.0.2
# homeassistant.components.bsblan
-python-bsblan==6.1.4
+python-bsblan==6.1.6
# homeassistant.components.citybikes
python-citybikes==0.3.3
@@ -2627,7 +2656,7 @@ python-digitalocean==1.13.2
python-dropbox-api==0.1.4
# homeassistant.components.duco
-python-duco-connectivity==0.7.1
+python-duco-connectivity==0.10.0
# homeassistant.components.ecobee
python-ecobee-api==0.4.1
@@ -2663,7 +2692,7 @@ python-homewizard-energy==10.1.0
python-hpilo==4.4.3
# homeassistant.components.izone
-python-izone==1.2.10
+python-izone==1.3.4
# homeassistant.components.joaoapps_join
python-join-api==0.1.1
@@ -2712,7 +2741,7 @@ python-picnic-api2==1.3.4
python-pooldose==0.9.6
# homeassistant.components.hr_energy_qube
-python-qube-heatpump==1.11.0
+python-qube-heatpump==1.12.0
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2721,7 +2750,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
-python-roborock==5.22.0
+python-roborock==5.30.0
# homeassistant.components.smarttub
python-smarttub==0.0.47
@@ -2736,7 +2765,7 @@ python-snoo==0.8.3
python-songpal==0.16.2
# homeassistant.components.swisscom
-python-swisscom-internet-box==0.1.1
+python-swisscom-internet-box==0.2.0
# homeassistant.components.tado
python-tado==0.18.16
@@ -2785,7 +2814,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.1.1
# homeassistant.components.v2c
-pytrydan==1.0.3
+pytrydan==1.0.4
# homeassistant.components.uptimerobot
pyuptimerobot==25.0.0
@@ -2827,7 +2856,7 @@ pywilight==0.0.74
pywizlight==0.6.3
# homeassistant.components.wmspro
-pywmspro==0.4.0
+pywmspro==0.4.2
# homeassistant.components.ws66i
pyws66i==1.1
@@ -2890,7 +2919,7 @@ renault-api==0.5.12
renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.21.3
+reolink-aio==0.21.4
# homeassistant.components.radio_frequency
rf-protocols==4.3.0
@@ -2960,7 +2989,7 @@ sendgrid==6.8.2
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense-energy==0.14.1
+sense-energy==0.14.3
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -3143,13 +3172,13 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==1.5.2
+tesla-fleet-api==1.7.2
# homeassistant.components.powerwall
tesla-powerwall==0.5.3
# homeassistant.components.tesla_wall_connector
-tesla-wall-connector==1.1.0
+tesla-wall-connector==1.2.0
# homeassistant.components.teslemetry
teslemetry-stream==0.9.1
@@ -3191,10 +3220,10 @@ tololib==1.2.2
toonapi==0.3.0
# homeassistant.components.totalconnect
-total-connect-client==2025.12.2
+total-connect-client==2026.7
# homeassistant.components.tplink_omada
-tplink-omada-client==1.5.8
+tplink-omada-client==1.5.9
# homeassistant.components.transmission
transmission-rpc==7.0.3
@@ -3212,7 +3241,7 @@ ttls==1.8.3
ttn_client==1.3.0
# homeassistant.components.tuya
-tuya-device-handlers==0.0.24
+tuya-device-handlers==0.0.25
# homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.10
@@ -3226,9 +3255,6 @@ twilio==6.32.0
# homeassistant.components.twitch
twitchAPI==4.2.1
-# homeassistant.components.monarch_money
-typedmonarchmoney==0.7.0
-
# homeassistant.components.ukraine_alarm
uasiren==0.0.1
@@ -3236,7 +3262,7 @@ uasiren==0.0.1
uhooapi==1.2.8
# homeassistant.components.unifiprotect
-uiprotect==15.4.0
+uiprotect==15.14.2
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.6.1
@@ -3280,7 +3306,7 @@ vehicle==3.0.0
velbus-aio==2026.4.1
# homeassistant.components.venstar
-venstarcolortouch==0.21
+venstarcolortouch==0.22
# homeassistant.components.viaggiatreno
viaggiatreno_ha==0.2.4
@@ -3289,7 +3315,7 @@ viaggiatreno_ha==0.2.4
victron-ble-ha-parser==0.7.0
# homeassistant.components.victron_gx
-victron-mqtt==2026.6.6
+victron-mqtt==2026.7.4
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.12
@@ -3359,7 +3385,7 @@ whois==0.9.27
wiffi==1.1.2
# homeassistant.components.wiim
-wiim==0.1.4
+wiim==0.1.5
# homeassistant.components.wirelesstag
wirelesstagpy==0.8.1
@@ -3444,10 +3470,10 @@ zeroconf==0.150.0
zeversolar==0.3.2
# homeassistant.components.zha
-zha-quirks==2.1.0
+zha-quirks==2.1.1
# homeassistant.components.zha
-zha==2.0.0
+zha==2.0.1
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
diff --git a/requirements_test.txt b/requirements_test.txt
index bd0ad2d1b002..797ef3a7fa85 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -8,15 +8,15 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
# ast-serialize is an internal mypy dependency
-ast-serialize==0.3.0
+ast-serialize==0.6.0
astroid==4.0.4
-coverage==7.14.3
+coverage==7.15.0
freezegun==1.5.5
# librt is an internal mypy dependency
-librt==0.11.0
+librt==0.13.0
license-expression==30.4.3
mock-open==1.4.0
-mypy==2.1.0
+mypy==2.3.0
prek==0.2.28
pydantic==2.13.4
PyGithub==2.9.1
@@ -38,7 +38,7 @@ pytest==9.0.3
requests==2.34.2
requests-mock==1.12.1
respx==0.23.1
-syrupy==5.3.2
+syrupy==5.5.2
tqdm==4.67.1
types-aiofiles==24.1.0.20250822
types-atomicwrites==1.4.5.1
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index 8ef2ed276ee8..a4fecbd60d51 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,6 +1,6 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.4.2
-ruff==0.15.20
+ruff==0.15.21
yamllint==1.38.0
zizmor==1.24.1
diff --git a/rootfs/etc/services.d/home-assistant/run b/rootfs/etc/services.d/home-assistant/run
index 40ec07c15431..950c6a6250e3 100755
--- a/rootfs/etc/services.d/home-assistant/run
+++ b/rootfs/etc/services.d/home-assistant/run
@@ -5,9 +5,14 @@
cd /config || bashio::exit.nok "Can't find config folder!"
-# Enable mimalloc for Home Assistant Core, unless disabled
-if [[ -z "${DISABLE_JEMALLOC+x}" ]]; then
- export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
- export MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000"
+# Use mimalloc as Python's object allocator by default. It is bundled in CPython
+# (3.13+), so no LD_PRELOAD or extra library is required, and it uses noticeably
+# less memory than the previously preloaded jemalloc. Override or disable via
+# PYTHONMALLOC, e.g. `PYTHONMALLOC=pymalloc` for the default allocator.
+export PYTHONMALLOC="${PYTHONMALLOC:-mimalloc}"
+
+if [[ -n "${DISABLE_JEMALLOC+x}" ]]; then
+ bashio::log.warning "DISABLE_JEMALLOC is set but no longer has any effect: jemalloc has been replaced by mimalloc. Set PYTHONMALLOC=pymalloc to use the default allocator instead."
fi
+
exec python3 -m homeassistant --config /config
diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py
index 3e118479b69b..5f36d3ef2ed0 100755
--- a/script/gen_copilot_instructions.py
+++ b/script/gen_copilot_instructions.py
@@ -17,6 +17,7 @@ INTEGRATION_SKILL_FILE = Path(".claude/skills/ha-integration-knowledge/SKILL.md"
INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE = Path(
".github/instructions/integrations.instructions.md"
)
+PR_TEMPLATE_FILE = Path(".github/PULL_REQUEST_TEMPLATE.md")
COPILOT_SPECIFIC_INSTRUCTIONS = """
# Copilot code review instructions
@@ -25,6 +26,15 @@ COPILOT_SPECIFIC_INSTRUCTIONS = """
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
+- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments. Even unchecked checkboxes or empty sections must be present. This is a hard requirement.
+
+## Pull Request template
+
+The PR description must follow this template (from `.github/PULL_REQUEST_TEMPLATE.md`):
+
+```markdown
+{pr_template}
+```
"""
INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS = """---
@@ -69,7 +79,15 @@ def generate_output() -> str:
print(f"Error: {AGENTS_FILE} not found")
sys.exit(1)
- output_parts: list[str] = [GENERATED_MESSAGE, COPILOT_SPECIFIC_INSTRUCTIONS]
+ if not PR_TEMPLATE_FILE.exists():
+ print(f"Error: {PR_TEMPLATE_FILE} not found")
+ sys.exit(1)
+
+ copilot_instructions = COPILOT_SPECIFIC_INSTRUCTIONS.replace(
+ "{pr_template}", PR_TEMPLATE_FILE.read_text().strip()
+ )
+
+ output_parts: list[str] = [GENERATED_MESSAGE, copilot_instructions]
# Add AGENTS.md content
agents_content = AGENTS_FILE.read_text()
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 63de1db99152..e70e25cf2d87 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -239,6 +239,10 @@ azure-kusto-data==4.5.1
azure-kusto-ingest==4.5.1
coloredlogs==15.0.1
setuptools==81.0.0
+
+# Pin cffi to 2.0.0 to avoid version mismatch with the pre-baked _cffi_backend in the base image.
+# https://github.com/home-assistant/core/issues/175832
+cffi==2.0.0
"""
GENERATED_MESSAGE = (
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index 9ca4987719f6..fb23e8b3c957 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -126,6 +126,7 @@ NO_IOT_CLASS = [
"temperature",
"timer",
"trace",
+ "vibration",
"web_rtc",
"webhook",
"websocket_api",
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
index 4a46a5ca06a3..b04a7c801e12 100644
--- a/script/hassfest/quality_scale.py
+++ b/script/hassfest/quality_scale.py
@@ -546,7 +546,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"london_underground",
"lookin",
"loqed",
- "luci",
"luftdaten",
"lupusec",
"lutron",
@@ -621,7 +620,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"nasweb",
"neato",
"nederlandse_spoorwegen",
- "netatmo",
"netdata",
"netgear",
"netgear_lte",
@@ -684,7 +682,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"otbr",
"otp",
"ourgroceries",
- "overkiz",
"ovo_energy",
"owntracks",
"p1_monitor",
@@ -862,7 +859,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"statsd",
"steam_online",
"steamist",
- "stiebel_eltron",
"stream",
"streamlabswater",
"subaru",
@@ -1498,7 +1494,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"london_underground",
"lookin",
"loqed",
- "luci",
"luftdaten",
"lupusec",
"lutron",
@@ -1576,7 +1571,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"nederlandse_spoorwegen",
"nest",
"ness_alarm",
- "netatmo",
"netdata",
"netgear",
"netgear_lte",
@@ -1651,7 +1645,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"panel_iframe",
"peco",
"pencom",
- "permobil",
"persistent_notification",
"person",
"philips_js",
@@ -1827,7 +1820,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"statsd",
"steam_online",
"steamist",
- "stiebel_eltron",
"stream",
"streamlabswater",
"stookwijzer",
@@ -2084,6 +2076,7 @@ NO_QUALITY_SCALE = [
"timer",
"trace",
"usage_prediction",
+ "vibration",
"web_rtc",
"webhook",
"websocket_api",
@@ -2095,7 +2088,7 @@ SCHEMA = vol.Schema(
{
vol.Required("rules"): vol.Schema(
{
- vol.Optional(rule.name): vol.Any(
+ vol.Required(rule.name): vol.Any(
vol.In(["todo", "done"]),
vol.Schema(
{
diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py
index 5ce6510ebd7b..db43686410e6 100644
--- a/script/hassfest/requirements.py
+++ b/script/hassfest/requirements.py
@@ -217,7 +217,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"surepetcare": {"surepy": {"async-timeout"}},
"tailwind": {"gotailwind": {"backoff"}},
"technove": {"python-technove": {"backoff"}},
- "tesla_wall_connector": {"tesla-wall-connector": {"backoff"}},
"tibber": {"gql": {"backoff"}},
"toon": {"toonapi": {"backoff"}},
"travisci": {
@@ -270,6 +269,8 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
"pbr": {"setuptools"}
},
"coinbase": {"homeassistant": {"coinbase-advanced-py"}},
+ # https://github.com/lawtancool/pyControl4 - ships tests/ in wheel
+ "control4": {"homeassistant": {"pycontrol4"}},
# https://github.com/u9n/dlms-cosem
"dsmr": {"dsmr-parser": {"dlms-cosem"}},
# https://github.com/tkdrob/pyefergy
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index 7369ed7b7310..f9f2d1df308d 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -45,7 +45,6 @@ ALLOW_NAME_TRANSLATION = {
"local_calendar",
"local_ip",
"local_todo",
- "modbus",
"nmap_tracker",
"remote_calendar",
"rpi_power",
diff --git a/script/licenses.py b/script/licenses.py
index 38a14d711c57..0d4dac8d84b7 100644
--- a/script/licenses.py
+++ b/script/licenses.py
@@ -83,6 +83,7 @@ OSI_APPROVED_LICENSES_SPDX = {
"LGPL-3.0-only",
"LGPL-3.0-or-later",
"MIT",
+ "MIT-0",
"MIT-CMU",
"MPL-1.1",
"MPL-2.0",
@@ -204,9 +205,6 @@ EXCEPTIONS = {
# fmt: off
TODO = {
"TravisPy": AwesomeVersion("0.3.5"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)']
- "aiocache": AwesomeVersion(
- "0.12.3"
- ), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved?
}
# fmt: on
diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py
index 19b2ab406674..226f375c2fbd 100644
--- a/script/scaffold/templates/config_flow/integration/config_flow.py
+++ b/script/scaffold/templates/config_flow/integration/config_flow.py
@@ -8,7 +8,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
@@ -94,9 +93,9 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN):
)
-class CannotConnect(HomeAssistantError):
+class CannotConnect(Exception):
"""Error to indicate we cannot connect."""
-class InvalidAuth(HomeAssistantError):
+class InvalidAuth(Exception):
"""Error to indicate there is invalid auth."""
diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py
index 66209f77e6a1..e2d8952396b1 100644
--- a/script/scaffold/templates/config_flow/tests/test_config_flow.py
+++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py
@@ -30,7 +30,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
CONF_PASSWORD: "test-password",
},
)
- await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Name of the device"
@@ -81,7 +80,6 @@ async def test_form_invalid_auth(
CONF_PASSWORD: "test-password",
},
)
- await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Name of the device"
@@ -133,7 +131,6 @@ async def test_form_cannot_connect(
CONF_PASSWORD: "test-password",
},
)
- await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Name of the device"
diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py
index cb96c9396c2b..df30a4b766dc 100644
--- a/tests/auth/permissions/test_entities.py
+++ b/tests/auth/permissions/test_entities.py
@@ -204,7 +204,14 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None:
},
)
device_registry = mock_device_registry(
- hass, {"mock-dev-id": DeviceEntry(id="mock-dev-id", area_id="mock-area-id")}
+ hass,
+ {
+ "mock-dev-id": DeviceEntry(
+ config_entry_id="mock-config-entry",
+ id="mock-dev-id",
+ area_id="mock-area-id",
+ )
+ },
)
policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}}
diff --git a/tests/common.py b/tests/common.py
index 474863bfaa17..60000058bff4 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -292,6 +292,7 @@ async def async_test_home_assistant(
)
},
)
+ hass.config_entries._initialized.set()
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP,
hass.config_entries._async_shutdown,
@@ -677,7 +678,7 @@ def mock_registry(
if mock_entries is None:
mock_entries = {}
registry.deleted_entities = {}
- registry.entities = er.EntityRegistryItems()
+ registry.entities = er.EntityRegistryItems(hass)
registry._entities_data = registry.entities.data
for key, entry in mock_entries.items():
registry.entities[key] = entry
@@ -763,7 +764,7 @@ def mock_device_registry(
mock_entries = {}
for key, entry in mock_entries.items():
registry.devices[key] = entry
- registry.deleted_devices = dr.DeviceRegistryItems()
+ registry.deleted_devices = dr.DeletedDeviceRegistryItems()
hass.data[dr.DATA_REGISTRY] = registry
return registry
diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py
index ff151f3b0969..e7b4fc2bf914 100644
--- a/tests/components/acaia/conftest.py
+++ b/tests/components/acaia/conftest.py
@@ -3,8 +3,8 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
-from aioacaia.acaiascale import AcaiaDeviceState
from aioacaia.const import UnitMass as AcaiaUnitOfMass
+from aioacaia.scale import AcaiaDeviceState
import pytest
from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr
index 9e3112606936..8ab2584f2948 100644
--- a/tests/components/acaia/snapshots/test_init.ambr
+++ b/tests/components/acaia/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device
DeviceRegistryEntrySnapshot({
'area_id': 'kitchen',
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': None,
'name': 'LUNAR-DDEEFF',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/ai_task/test_media_source.py b/tests/components/ai_task/test_media_source.py
index f41992f74cde..883f3953562b 100644
--- a/tests/components/ai_task/test_media_source.py
+++ b/tests/components/ai_task/test_media_source.py
@@ -26,6 +26,9 @@ async def test_local_media_source(hass: HomeAssistant, init_components: None) ->
)
assert source.url_prefix == "/ai_task"
+
+async def test_media_source_no_media_dirs(hass: HomeAssistant) -> None:
+ """Test an error is raised when no media directories are configured."""
hass.config.media_dirs = {}
with pytest.raises(
diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py
index a15cec0b466c..2a5add49a9bc 100644
--- a/tests/components/ai_task/test_task.py
+++ b/tests/components/ai_task/test_task.py
@@ -16,10 +16,11 @@ from homeassistant.components.ai_task import (
from homeassistant.components.ai_task.const import DATA_MEDIA_SOURCE
from homeassistant.components.camera import Image
from homeassistant.components.conversation import async_get_chat_log
+from homeassistant.components.llm import AssistAPI
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import chat_session, llm
+from homeassistant.helpers import chat_session
from homeassistant.util import dt as dt_util
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
@@ -77,7 +78,7 @@ async def test_generate_data_preferred_entity(
assert state is not None
assert state.state == STATE_UNKNOWN
- llm_api = llm.AssistAPI(hass)
+ llm_api = AssistAPI(hass)
result = await async_generate_data(
hass,
task_name="Test Task",
diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr
index 2a1e3dcc7fd7..b5af12e65a2e 100644
--- a/tests/components/airgradient/snapshots/test_init.ambr
+++ b/tests/components/airgradient/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_info[indoor]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': 'I-9PSL',
'name': 'Airgradient',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': '84fce612f5b8',
'sw_version': '3.1.1',
'via_device_id': None,
@@ -37,8 +36,8 @@
# name: test_device_info[outdoor]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
tuple(
@@ -63,7 +62,6 @@
'model_id': 'O-1PPT',
'name': 'Airgradient',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': '84fce612f5b8',
'sw_version': '3.1.1',
'via_device_id': None,
diff --git a/tests/components/airnow/fixtures/response.json b/tests/components/airnow/fixtures/response.json
index 91029f5531f2..63877e167a90 100644
--- a/tests/components/airnow/fixtures/response.json
+++ b/tests/components/airnow/fixtures/response.json
@@ -1,47 +1,47 @@
[
{
- "DateObserved": "2020-12-20",
- "HourObserved": 15,
- "LocalTimeZone": "PST",
- "ReportingArea": "Central LA CO",
- "StateCode": "CA",
- "Latitude": 34.0663,
- "Longitude": -118.2266,
- "ParameterName": "O3",
- "AQI": 44,
- "Category": {
- "Number": 1,
- "Name": "Good"
- }
+ "dateObserved": "2020-12-20",
+ "hourObserved": "15:00",
+ "localTimeZone": "PST",
+ "reportingAreaName": "Central LA CO",
+ "siteID": "060371103",
+ "siteName": "Los Angeles - N. Main Street",
+ "parameterName": "OZONE",
+ "nowcastAQI": 44,
+ "aqiCategoryName": "Good",
+ "reportingAgency": "South Coast AQMD",
+ "lookupBehavior": "Closest Reading By Pollutant",
+ "consideredMonitors": "All",
+ "lookupBoundary": "50 Miles"
},
{
- "DateObserved": "2020-12-20",
- "HourObserved": 15,
- "LocalTimeZone": "PST",
- "ReportingArea": "Central LA CO",
- "StateCode": "CA",
- "Latitude": 34.0663,
- "Longitude": -118.2266,
- "ParameterName": "PM2.5",
- "AQI": 37,
- "Category": {
- "Number": 1,
- "Name": "Good"
- }
+ "dateObserved": "2020-12-20",
+ "hourObserved": "15:00",
+ "localTimeZone": "PST",
+ "reportingAreaName": "Central LA CO",
+ "siteID": "060371103",
+ "siteName": "Los Angeles - N. Main Street",
+ "parameterName": "PM2.5",
+ "nowcastAQI": 37,
+ "aqiCategoryName": "Good",
+ "reportingAgency": "South Coast AQMD",
+ "lookupBehavior": "Closest Reading By Pollutant",
+ "consideredMonitors": "All",
+ "lookupBoundary": "50 Miles"
},
{
- "DateObserved": "2020-12-20",
- "HourObserved": 15,
- "LocalTimeZone": "PST",
- "ReportingArea": "Central LA CO",
- "StateCode": "CA",
- "Latitude": 34.0663,
- "Longitude": -118.2266,
- "ParameterName": "PM10",
- "AQI": 11,
- "Category": {
- "Number": 1,
- "Name": "Good"
- }
+ "dateObserved": "2020-12-20",
+ "hourObserved": "15:00",
+ "localTimeZone": "PST",
+ "reportingAreaName": "Central LA CO",
+ "siteID": "060371103",
+ "siteName": "Los Angeles - N. Main Street",
+ "parameterName": "PM10",
+ "nowcastAQI": 11,
+ "aqiCategoryName": "Good",
+ "reportingAgency": "South Coast AQMD",
+ "lookupBehavior": "Closest Reading By Pollutant",
+ "consideredMonitors": "All",
+ "lookupBoundary": "50 Miles"
}
]
diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr
index d711f9c2eba1..72cb584adc6a 100644
--- a/tests/components/airnow/snapshots/test_diagnostics.ambr
+++ b/tests/components/airnow/snapshots/test_diagnostics.ambr
@@ -7,15 +7,15 @@
'Category.Number': 1,
'DateObserved': '2020-12-20',
'HourObserved': 15,
- 'Latitude': '**REDACTED**',
+ 'Latitude': None,
'LocalTimeZone': 'PST',
- 'Longitude': '**REDACTED**',
+ 'Longitude': None,
'O3': 0.048,
'PM10': 12,
'PM2.5': 6.7,
'Pollutant': 'O3',
'ReportingArea': '**REDACTED**',
- 'StateCode': '**REDACTED**',
+ 'StateCode': '',
}),
'entry': dict({
'data': dict({
diff --git a/tests/components/airobot/snapshots/test_init.ambr b/tests/components/airobot/snapshots/test_init.ambr
index b7e2957b834c..7e62c1dff0c2 100644
--- a/tests/components/airobot/snapshots/test_init.ambr
+++ b/tests/components/airobot/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_entry
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': 'TE1',
'name': 'Test Thermostat',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': '1.44',
'via_device_id': None,
diff --git a/tests/components/airobot/test_button.py b/tests/components/airobot/test_button.py
index 529605836a99..4f638122926c 100644
--- a/tests/components/airobot/test_button.py
+++ b/tests/components/airobot/test_button.py
@@ -71,30 +71,6 @@ async def test_restart_button_error(
mock_airobot_client.reboot_thermostat.assert_called_once()
-@pytest.mark.usefixtures("init_integration")
-@pytest.mark.parametrize(
- "exception",
- [AirobotConnectionError("Connection lost"), AirobotTimeoutError("Timeout")],
-)
-async def test_restart_button_connection_errors(
- hass: HomeAssistant,
- mock_airobot_client: AsyncMock,
- exception: Exception,
-) -> None:
- """Test restart button handles connection/timeout errors gracefully."""
- mock_airobot_client.reboot_thermostat.side_effect = exception
-
- # Should not raise an error - connection errors during reboot are expected
- await hass.services.async_call(
- BUTTON_DOMAIN,
- SERVICE_PRESS,
- {ATTR_ENTITY_ID: "button.test_thermostat_restart"},
- blocking=True,
- )
-
- mock_airobot_client.reboot_thermostat.assert_called_once()
-
-
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_recalibrate_co2_button(
hass: HomeAssistant,
@@ -112,12 +88,21 @@ async def test_recalibrate_co2_button(
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+@pytest.mark.parametrize(
+ "exception",
+ [
+ AirobotError("Test error"),
+ AirobotConnectionError("Connection lost"),
+ AirobotTimeoutError("Timeout"),
+ ],
+)
async def test_recalibrate_co2_button_error(
hass: HomeAssistant,
mock_airobot_client: AsyncMock,
+ exception: Exception,
) -> None:
"""Test recalibrate CO2 sensor button error handling."""
- mock_airobot_client.recalibrate_co2_sensor.side_effect = AirobotError("Test error")
+ mock_airobot_client.recalibrate_co2_sensor.side_effect = exception
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
diff --git a/tests/components/airthings/snapshots/test_sensor.ambr b/tests/components/airthings/snapshots/test_sensor.ambr
index b6764ea68b75..c69c031c1f60 100644
--- a/tests/components/airthings/snapshots/test_sensor.ambr
+++ b/tests/components/airthings/snapshots/test_sensor.ambr
@@ -380,24 +380,25 @@
'suggested_display_precision': 0,
}),
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Radon',
'platform': 'airthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
- 'translation_key': 'radon',
+ 'translation_key': None,
'unique_id': '2960000001_radonShortTermAvg',
- 'unit_of_measurement': 'Bq/m³',
+ 'unit_of_measurement': ,
})
# ---
# name: test_all_device_types[view_plus][sensor.living_room_radon-state]
StateSnapshot({
'attributes': ReadOnlyDict({
+ : 'radon',
: 'Living Room Radon',
: ,
- : 'Bq/m³',
+ : ,
}),
'context': ,
'entity_id': 'sensor.living_room_radon',
@@ -1255,24 +1256,25 @@
'suggested_display_precision': 0,
}),
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Radon',
'platform': 'airthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
- 'translation_key': 'radon',
+ 'translation_key': None,
'unique_id': '2930000002_radonShortTermAvg',
- 'unit_of_measurement': 'Bq/m³',
+ 'unit_of_measurement': ,
})
# ---
# name: test_all_device_types[wave_plus][sensor.office_radon-state]
StateSnapshot({
'attributes': ReadOnlyDict({
+ : 'radon',
: 'Office Radon',
: ,
- : 'Bq/m³',
+ : ,
}),
'context': ,
'entity_id': 'sensor.office_radon',
diff --git a/tests/components/airvisual_pro/snapshots/test_init.ambr b/tests/components/airvisual_pro/snapshots/test_init.ambr
index e2fef8910baa..d0bf6ac327d8 100644
--- a/tests/components/airvisual_pro/snapshots/test_init.ambr
+++ b/tests/components/airvisual_pro/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': None,
'name': 'Office',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': '1.1826',
'via_device_id': None,
diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py
index 0c49ce7864fa..28219ec758dc 100644
--- a/tests/components/alexa_devices/const.py
+++ b/tests/components/alexa_devices/const.py
@@ -23,7 +23,7 @@ TEST_DEVICE_1_SN = "echo_test_serial_number"
TEST_DEVICE_1_ID = "echo_test_device_id"
TEST_DEVICE_1 = AmazonDevice(
account_name="Echo Test",
- capabilities=["AUDIO_PLAYER", "MICROPHONE"],
+ capabilities=["AUDIO_PLAYER", "MICROPHONE", "ALEXA_DEVICE_REBOOT"],
device_family="mine",
device_type="echo",
household_device=False,
@@ -87,7 +87,7 @@ TEST_DEVICE_1 = AmazonDevice(
TEST_DEVICE_2_SN = "echo_test_2_serial_number"
TEST_DEVICE_2 = AmazonDevice(
account_name="Echo Test 2",
- capabilities=["AUDIO_PLAYER", "MICROPHONE"],
+ capabilities=["AUDIO_PLAYER", "MICROPHONE", "ALEXA_DEVICE_REBOOT"],
device_family="mine",
device_type="echo",
household_device=True,
diff --git a/tests/components/alexa_devices/snapshots/test_button.ambr b/tests/components/alexa_devices/snapshots/test_button.ambr
index b2dbec7a96f2..8482acd11f60 100644
--- a/tests/components/alexa_devices/snapshots/test_button.ambr
+++ b/tests/components/alexa_devices/snapshots/test_button.ambr
@@ -1,4 +1,55 @@
# serializer version: 1
+# name: test_all_entities[button.echo_test_restart-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'button',
+ 'entity_category': ,
+ 'entity_id': 'button.echo_test_restart',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Restart',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Restart',
+ 'platform': 'alexa_devices',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'echo_test_serial_number-restart',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_all_entities[button.echo_test_restart-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'restart',
+ : 'Echo Test Restart',
+ }),
+ 'context': ,
+ 'entity_id': 'button.echo_test_restart',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
# name: test_all_entities[button.fake_email_gmail_com_test_routine-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr
index 8c30470005f6..06073892f9ea 100644
--- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr
+++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr
@@ -5,6 +5,7 @@
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
+ 'ALEXA_DEVICE_REBOOT',
]),
'device cluster members': dict({
'echo_test_serial_number': 'echo_test_device_id',
@@ -43,6 +44,7 @@
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
+ 'ALEXA_DEVICE_REBOOT',
]),
'device cluster members': dict({
'echo_test_serial_number': 'echo_test_device_id',
diff --git a/tests/components/alexa_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr
index e4ae777da32b..ded6fc40e291 100644
--- a/tests/components/alexa_devices/snapshots/test_init.ambr
+++ b/tests/components/alexa_devices/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
}),
@@ -24,7 +24,6 @@
'model_id': 'echo',
'name': 'Echo Test',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': 'echo_test_serial_number',
'sw_version': 'echo_test_software_version',
'via_device_id': None,
diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr
index cf333f088247..511de9b64ff3 100644
--- a/tests/components/alexa_devices/snapshots/test_services.ambr
+++ b/tests/components/alexa_devices/snapshots/test_services.ambr
@@ -7,6 +7,7 @@
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
+ 'ALEXA_DEVICE_REBOOT',
]),
'communication_settings': dict({
'announcements': 'ON',
@@ -83,6 +84,7 @@
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
+ 'ALEXA_DEVICE_REBOOT',
]),
'communication_settings': dict({
'announcements': 'ON',
@@ -159,6 +161,7 @@
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
+ 'ALEXA_DEVICE_REBOOT',
]),
'communication_settings': dict({
'announcements': 'ON',
diff --git a/tests/components/alexa_devices/test_button.py b/tests/components/alexa_devices/test_button.py
index a1bba9b7e1a9..265ae612b208 100644
--- a/tests/components/alexa_devices/test_button.py
+++ b/tests/components/alexa_devices/test_button.py
@@ -14,7 +14,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.util import slugify
from . import setup_integration
-from .const import TEST_USERNAME
+from .const import TEST_DEVICE_1, TEST_USERNAME
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -94,3 +94,21 @@ async def test_dynamic_entities(
for routine in set(initial_routine) - set(updated_routines):
entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}"
assert hass.states.get(entity_id) is None
+
+
+async def test_restart_button(
+ hass: HomeAssistant,
+ mock_amazon_devices_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test restart button."""
+
+ await setup_integration(hass, mock_config_entry)
+
+ await hass.services.async_call(
+ BUTTON_DOMAIN,
+ SERVICE_PRESS,
+ {ATTR_ENTITY_ID: f"button.{slugify(TEST_DEVICE_1.account_name)}_restart"},
+ blocking=True,
+ )
+ mock_amazon_devices_client.restart_device.assert_called_once()
diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py
index dff813529d26..e798b3ceeb01 100644
--- a/tests/components/alexa_devices/test_init.py
+++ b/tests/components/alexa_devices/test_init.py
@@ -12,7 +12,12 @@ from homeassistant.components.alexa_devices.const import (
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
-from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import (
+ CONF_COUNTRY,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ EVENT_HOMEASSISTANT_STOP,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -205,3 +210,17 @@ async def test_http2_stop_processing_called_on_unload(
await hass.async_block_till_done()
mock_amazon_devices_client.stop_http2_processing.assert_awaited_once()
+
+
+async def test_http2_stop_processing_called_on_shutdown(
+ hass: HomeAssistant,
+ mock_amazon_devices_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test stop_http2_processing is awaited when Home Assistant stops."""
+ await setup_integration(hass, mock_config_entry)
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ await hass.async_block_till_done()
+
+ mock_amazon_devices_client.stop_http2_processing.assert_awaited_once()
diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py
index 1a500da5ea85..7d63e61a3aa1 100644
--- a/tests/components/alexa_devices/test_services.py
+++ b/tests/components/alexa_devices/test_services.py
@@ -158,7 +158,9 @@ async def test_invalid_parameters(
"""Test invalid service parameters."""
device_entry = dr.DeviceEntry(
- id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
+ config_entry_id=mock_config_entry.entry_id,
+ id=TEST_DEVICE_1_ID,
+ identifiers={(DOMAIN, TEST_DEVICE_1_SN)},
)
mock_device_registry(
hass,
@@ -214,7 +216,9 @@ async def test_invalid_info_skillparameters(
"""Test invalid info skill service parameters."""
device_entry = dr.DeviceEntry(
- id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
+ config_entry_id=mock_config_entry.entry_id,
+ id=TEST_DEVICE_1_ID,
+ identifiers={(DOMAIN, TEST_DEVICE_1_SN)},
)
mock_device_registry(
hass,
@@ -278,21 +282,21 @@ async def test_config_entry_not_loaded(
async def test_invalid_config_entry(
hass: HomeAssistant,
- device_registry: dr.DeviceRegistry,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
- """Test that a non-existing entry ID in device config entries is skipped."""
+ """Test that a device pointing to a non-existing config entry ID is skipped."""
- await setup_integration(hass, mock_config_entry)
-
- device_entry = device_registry.async_get_device(
- identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
+ device_entry = dr.DeviceEntry(
+ config_entry_id="non_existing_entry_id",
+ id=TEST_DEVICE_1_ID,
+ identifiers={(DOMAIN, TEST_DEVICE_1_SN)},
)
- assert device_entry
-
- device_entry.config_entries.clear()
- device_entry.config_entries.add("non_existing_entry_id")
+ mock_device_registry(
+ hass,
+ {device_entry.id: device_entry},
+ )
+ await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
@@ -300,14 +304,14 @@ async def test_invalid_config_entry(
"send_sound",
{
ATTR_SOUND: "bell_02",
- ATTR_DEVICE_ID: device_entry.id,
+ ATTR_DEVICE_ID: TEST_DEVICE_1_ID,
},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "config_entry_not_found"
- assert exc_info.value.translation_placeholders == {"device_id": device_entry.id}
+ assert exc_info.value.translation_placeholders == {"device_id": TEST_DEVICE_1_ID}
async def test_missing_config_entry(
@@ -316,7 +320,7 @@ async def test_missing_config_entry(
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
- """Test missing config entry."""
+ """Test that a device not owned by an Alexa config entry is rejected."""
await setup_integration(hass, mock_config_entry)
@@ -325,7 +329,15 @@ async def test_missing_config_entry(
)
assert device_entry
- device_entry.config_entries.clear()
+ # Move the device to a config entry from a different integration
+ other_entry = MockConfigEntry(domain="other_domain", data={})
+ other_entry.add_to_hass(hass)
+ device_registry.async_update_device(
+ device_entry.id, add_config_entry_id=other_entry.entry_id
+ )
+ device_registry.async_update_device(
+ device_entry.id, remove_config_entry_id=mock_config_entry.entry_id
+ )
# Call Service
with pytest.raises(ServiceValidationError) as exc_info:
diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json
index 9adc76144d5f..bc8f5cdc2967 100644
--- a/tests/components/analytics_insights/fixtures/current_data.json
+++ b/tests/components/analytics_insights/fixtures/current_data.json
@@ -1199,7 +1199,6 @@
"fritzbox_netmonitor": 4,
"apprise": 2,
"drop_connect": 1,
- "permobil": 3,
"norway_air": 3,
"push": 2,
"upc_connect": 2,
diff --git a/tests/components/anthemav/snapshots/test_init.ambr b/tests/components/anthemav/snapshots/test_init.ambr
index 1bd187b1c9fb..ecda7f5df8af 100644
--- a/tests/components/anthemav/snapshots/test_init.ambr
+++ b/tests/components/anthemav/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': None,
'name': 'Anthem AV',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr
index fae45df65f99..bc2af2e73473 100644
--- a/tests/components/anthropic/snapshots/test_conversation.ambr
+++ b/tests/components/anthropic/snapshots/test_conversation.ambr
@@ -380,7 +380,7 @@
You are a voice assistant for Home Assistant.
Answer questions about the world truthfully.
Answer in plain text. Keep it simple and to the point.
- Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
+
Current time is 16:00:00. Today's date is 2024-06-03.
''',
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
diff --git a/tests/components/anthropic/test_ai_task.py b/tests/components/anthropic/test_ai_task.py
index bfe4e2b4274e..59aa1f0e4987 100644
--- a/tests/components/anthropic/test_ai_task.py
+++ b/tests/components/anthropic/test_ai_task.py
@@ -579,3 +579,51 @@ async def test_generate_data_invalid_attachments(
{"media_content_id": "media-source://media/doorbell_snapshot.txt"},
],
)
+
+
+async def test_generate_data_with_attachments_whitespace_instructions(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_init_component,
+ mock_create_stream: AsyncMock,
+) -> None:
+ """Test whitespace-only instructions with attachments produce no text block.
+
+ The API rejects whitespace-only text blocks, so the user message should
+ contain only the attachment.
+ """
+ entity_id = "ai_task.claude_ai_task"
+
+ mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])]
+
+ with (
+ patch(
+ "homeassistant.components.media_source.async_resolve_media",
+ side_effect=[
+ media_source.PlayMedia(
+ url="http://example.com/doorbell_snapshot.jpg",
+ mime_type="image/jpg",
+ path=Path("doorbell_snapshot.jpg"),
+ ),
+ ],
+ ),
+ patch("pathlib.Path.exists", return_value=True),
+ patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"),
+ ):
+ result = await ai_task.async_generate_data(
+ hass,
+ task_name="Test Task",
+ entity_id=entity_id,
+ instructions=" ",
+ attachments=[
+ {"media_content_id": "media-source://media/doorbell_snapshot.jpg"},
+ ],
+ )
+
+ assert result.data == "Hi there!"
+
+ input_messages = mock_create_stream.call_args[1]["messages"]
+ user_message = input_messages[-2]
+ assert user_message["role"] == "user"
+ assert isinstance(user_message["content"], list)
+ assert [block["type"] for block in user_message["content"]] == ["image"]
diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py
index bf123f6771b1..2a0188c4bf1d 100644
--- a/tests/components/anthropic/test_conversation.py
+++ b/tests/components/anthropic/test_conversation.py
@@ -1,6 +1,7 @@
"""Tests for the Anthropic integration."""
import datetime
+from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
@@ -56,9 +57,14 @@ from homeassistant.components.anthropic.const import (
CONF_WEB_SEARCH_USER_LOCATION,
DOMAIN,
)
-from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails
+from homeassistant.components.anthropic.entity import (
+ CitationDetails,
+ ContentDetails,
+ _convert_content,
+)
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.intent import async_register_timer_handler
+from homeassistant.components.llm import LLMTools
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -319,7 +325,7 @@ async def test_prompt_caching_automatic(
assert isinstance(system, str)
-@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools")
+@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock)
@pytest.mark.parametrize(
("tool_call_json_parts", "expected_call_tool_args"),
[
@@ -356,7 +362,7 @@ async def test_function_call(
)
mock_tool.async_call.return_value = "Test response"
- mock_get_tools.return_value = [mock_tool]
+ mock_get_tools.return_value = LLMTools(tools=[mock_tool])
mock_create_stream.return_value = [
(
@@ -416,7 +422,7 @@ async def test_function_call(
)
-@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools")
+@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock)
async def test_function_exception(
mock_get_tools,
hass: HomeAssistant,
@@ -436,7 +442,7 @@ async def test_function_exception(
)
mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception")
- mock_get_tools.return_value = [mock_tool]
+ mock_get_tools.return_value = LLMTools(tools=[mock_tool])
mock_create_stream.return_value = [
(
@@ -847,7 +853,7 @@ async def test_redacted_thinking(
assert chat_log.content[1:] == snapshot
-@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools")
+@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock)
async def test_extended_thinking_tool_call(
mock_get_tools,
hass: HomeAssistant,
@@ -879,7 +885,7 @@ async def test_extended_thinking_tool_call(
)
mock_tool.async_call.return_value = "Test response"
- mock_get_tools.return_value = [mock_tool]
+ mock_get_tools.return_value = LLMTools(tools=[mock_tool])
mock_create_stream.return_value = [
(
@@ -2392,3 +2398,103 @@ async def test_history_conversion(
)
assert mock_create_stream.mock_calls[0][2]["messages"] == snapshot
+
+
+async def test_history_conversion_skips_whitespace_content(
+ hass: HomeAssistant,
+ mock_config_entry_with_assist: MockConfigEntry,
+ mock_init_component,
+ mock_create_stream: AsyncMock,
+) -> None:
+ """Test that whitespace-only chat log content is not sent to the API.
+
+ The API rejects text content blocks that contain only whitespace, and a
+ single such entry in a reused chat session would fail every following turn.
+ """
+ conversation_id = "conversation_id"
+ mock_create_stream.return_value = [create_content_block(0, ["Yes, I am sure!"])]
+ with (
+ chat_session.async_get_chat_session(hass, conversation_id) as session,
+ conversation.async_get_chat_log(hass, session) as chat_log,
+ ):
+ chat_log.content = [
+ conversation.chat_log.SystemContent("You are a helpful assistant."),
+ conversation.chat_log.UserContent("What shape is a donut?"),
+ conversation.chat_log.AssistantContent(
+ agent_id="conversation.claude_conversation", content="\n"
+ ),
+ conversation.chat_log.UserContent(" "),
+ conversation.chat_log.AssistantContent(
+ agent_id="conversation.claude_conversation", content="Round."
+ ),
+ ]
+
+ await conversation.async_converse(
+ hass,
+ "Are you sure?",
+ conversation_id,
+ Context(),
+ agent_id="conversation.claude_conversation",
+ )
+
+ assert mock_create_stream.mock_calls[0][2]["messages"] == [
+ {"role": "user", "content": "What shape is a donut?"},
+ {"role": "assistant", "content": "Round."},
+ {"role": "user", "content": "Are you sure?"},
+ {"role": "assistant", "content": "Yes, I am sure!"},
+ ]
+
+
+def test_convert_content_whitespace_with_attachments() -> None:
+ """Test conversion of whitespace-only user content carrying attachments.
+
+ Attachments are only appended to the last message afterwards, so an empty
+ user message is only created when the content is the last entry; earlier
+ whitespace-only entries are dropped even if they carry attachments.
+ """
+ attachment = conversation.chat_log.Attachment(
+ media_content_id="media-source://media/doorbell_snapshot.jpg",
+ mime_type="image/jpg",
+ path=Path("doorbell_snapshot.jpg"),
+ )
+
+ # Not the last entry: dropped, surrounding user messages are combined
+ messages, _ = _convert_content(
+ [
+ conversation.chat_log.UserContent("Take a look"),
+ conversation.chat_log.UserContent(" ", attachments=[attachment]),
+ conversation.chat_log.UserContent("What do you see?"),
+ ]
+ )
+ assert messages == [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "Take a look"},
+ {"type": "text", "text": "What do you see?"},
+ ],
+ },
+ ]
+
+ # Last entry preceded by a user message: no text block is added, the
+ # attachments are appended to the preceding message afterwards
+ messages, _ = _convert_content(
+ [
+ conversation.chat_log.UserContent("Take a look"),
+ conversation.chat_log.UserContent(" ", attachments=[attachment]),
+ ]
+ )
+ assert messages == [
+ {"role": "user", "content": "Take a look"},
+ ]
+
+ # Last entry with no preceding user message: an empty message is created
+ # for the attachments to be appended to afterwards
+ messages, _ = _convert_content(
+ [
+ conversation.chat_log.UserContent(" ", attachments=[attachment]),
+ ]
+ )
+ assert messages == [
+ {"role": "user", "content": []},
+ ]
diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py
index 3c1505ff54f3..7a2b1379dcb6 100644
--- a/tests/components/anthropic/test_init.py
+++ b/tests/components/anthropic/test_init.py
@@ -716,7 +716,7 @@ async def test_migration_from_v2_1_to_v2_2(
device_1 = device_registry.async_update_device(
device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None
)
- assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}}
+ assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}}
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr
index 057619a02463..ec7af18eef8f 100644
--- a/tests/components/aosmith/snapshots/test_device.ambr
+++ b/tests/components/aosmith/snapshots/test_device.ambr
@@ -2,8 +2,8 @@
# name: test_device
DeviceRegistryEntrySnapshot({
'area_id': 'basement',
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
}),
@@ -24,7 +24,6 @@
'model_id': None,
'name': 'My water heater',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': 'serial',
'sw_version': '2.14',
'via_device_id': None,
diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr
index 3309d384ec75..e51ed8bfad07 100644
--- a/tests/components/apcupsd/snapshots/test_init.ambr
+++ b/tests/components/apcupsd/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_async_setup_entry[mock_request_status0-mocked-config-entry-id][device_MyUPS_XXXXXXXXXXXX]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
}),
@@ -24,7 +24,6 @@
'model_id': None,
'name': 'MyUPS',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': 'XXXXXXXXXXXX',
'sw_version': '3.14.14 (31 May 2016) unknown',
'via_device_id': None,
@@ -33,8 +32,8 @@
# name: test_async_setup_entry[mock_request_status1-mocked-config-entry-id][device_APC UPS_XXXX]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
}),
@@ -55,7 +54,6 @@
'model_id': None,
'name': 'APC UPS',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': 'XXXX',
'sw_version': None,
'via_device_id': None,
@@ -64,8 +62,8 @@
# name: test_async_setup_entry[mock_request_status2-mocked-config-entry-id][device_APC UPS_]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
}),
@@ -86,7 +84,6 @@
'model_id': None,
'name': 'APC UPS',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
@@ -95,8 +92,8 @@
# name: test_async_setup_entry[mock_request_status3-mocked-config-entry-id][device_APC UPS_Blank]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
}),
@@ -117,7 +114,6 @@
'model_id': None,
'name': 'APC UPS',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py
index df95342e9bcf..7db0e2fc5679 100644
--- a/tests/components/api/test_init.py
+++ b/tests/components/api/test_init.py
@@ -51,6 +51,33 @@ async def test_api_list_state_entities(
assert remote_data == local_data
+@pytest.mark.parametrize(
+ ("entity_count", "expect_compression"),
+ [
+ pytest.param(1, False, id="small-body-not-compressed"),
+ pytest.param(50, True, id="large-body-compressed"),
+ ],
+)
+async def test_api_states_compression_threshold(
+ hass: HomeAssistant,
+ mock_api_client: TestClient,
+ entity_count: int,
+ expect_compression: bool,
+) -> None:
+ """Test that only state list responses above the size threshold are compressed."""
+ for i in range(entity_count):
+ hass.states.async_set(
+ f"test.entity_{i}", "on", {"friendly_name": f"Entity {i}"}
+ )
+
+ resp = await mock_api_client.get(
+ const.URL_API_STATES, headers={"Accept-Encoding": "gzip, deflate"}
+ )
+
+ assert resp.status == HTTPStatus.OK
+ assert ("Content-Encoding" in resp.headers) is expect_compression
+
+
async def test_api_get_state(hass: HomeAssistant, mock_api_client: TestClient) -> None:
"""Test if the debug interface allows us to get a state."""
hass.states.async_set("hello.world", "nice", {"attr": 1})
diff --git a/tests/components/aprilaire/snapshots/test_init.ambr b/tests/components/aprilaire/snapshots/test_init.ambr
index e4fb26e5272c..96711abda099 100644
--- a/tests/components/aprilaire/snapshots/test_init.ambr
+++ b/tests/components/aprilaire/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': None,
'name': 'Aprilaire',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': '1.05',
'via_device_id': None,
diff --git a/tests/components/aqualogic/conftest.py b/tests/components/aqualogic/conftest.py
index 2873f0f2c626..dda1d7c672da 100644
--- a/tests/components/aqualogic/conftest.py
+++ b/tests/components/aqualogic/conftest.py
@@ -1,14 +1,28 @@
-"""Fixtures for AquaLogic tests."""
+"""Fixtures for the AquaLogic integration tests."""
-from collections.abc import Callable
+from collections.abc import AsyncGenerator, Generator
+from datetime import timedelta
from unittest.mock import MagicMock, patch
import pytest
-from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor
-from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.components.aqualogic import AquaLogicProcessor
+from homeassistant.components.aqualogic.const import DOMAIN, UPDATE_TOPIC
+from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.setup import async_setup_component
+from homeassistant.helpers.dispatcher import dispatcher_send
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_config_entry() -> MockConfigEntry:
+ """Return a mock config entry."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ entry_id="test_aqualogic_entry",
+ )
@pytest.fixture
@@ -30,47 +44,69 @@ def mock_panel() -> MagicMock:
@pytest.fixture
-def mock_processor(mock_panel: MagicMock) -> MagicMock:
- """Return a mock AquaLogicProcessor registered in hass.data."""
- with patch("homeassistant.components.aqualogic.AquaLogicProcessor") as mock_cls:
- processor = MagicMock()
+def mock_aqualogic_device() -> Generator[MagicMock]:
+ """Return a mock AquaLogic device that immediately triggers the data callback."""
+ with patch(
+ "homeassistant.components.aqualogic.config_flow.AquaLogic"
+ ) as mock_al_class:
+
+ def _fake_process(callback: object) -> None:
+ callback(mock_al_class.return_value)
+
+ mock_al_class.return_value.process.side_effect = _fake_process
+ yield mock_al_class
+
+
+@pytest.fixture
+def mock_processor(hass: HomeAssistant, mock_panel: MagicMock) -> Generator[MagicMock]:
+ """Mock the AquaLogic processor thread."""
+ with patch(
+ "homeassistant.components.aqualogic.AquaLogicProcessor"
+ ) as mock_processor_class:
+ processor = mock_processor_class.return_value
processor.panel = mock_panel
- mock_cls.return_value = processor
+ processor.data_changed.side_effect = lambda _: dispatcher_send(
+ hass, UPDATE_TOPIC
+ )
yield processor
@pytest.fixture
async def init_integration(
- hass: HomeAssistant, mock_panel: MagicMock
-) -> AquaLogicProcessor:
- """Set up the AquaLogic integration and run one pass of run() to register the callback.
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_processor: MagicMock,
+ platforms: list[Platform],
+) -> MockConfigEntry:
+ """Set up the AquaLogic integration for testing."""
+ mock_config_entry.add_to_hass(hass)
- AquaLogic is mocked so mock_panel becomes processor.panel. _shutdown is set before
- run() so it exits after a single iteration, registering the data_changed callback
- with panel.process() without starting a real network thread.
- """
- with patch("homeassistant.components.aqualogic.AquaLogic") as mock_al:
- mock_al.return_value = mock_panel
- assert await async_setup_component(
- hass,
- DOMAIN,
- {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}},
- )
+ with patch("homeassistant.components.aqualogic.PLATFORMS", platforms):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
- processor: AquaLogicProcessor = hass.data[DOMAIN]
- processor._shutdown = True
- processor.run()
- return processor
+
+ return mock_config_entry
@pytest.fixture
-def update_callback(
- init_integration: AquaLogicProcessor, mock_panel: MagicMock
-) -> Callable[[], None]:
- """Return a callable that fires a panel data update through the registered callback.
+def platforms() -> list[Platform]:
+ """Fixture to specify platforms to test."""
+ return [Platform.SENSOR, Platform.SWITCH]
- Extracts the data_changed callback from the mock's process() call args so tests
- trigger updates through the same path as real panel data arriving.
- """
- callback = mock_panel.process.call_args[0][0]
- return lambda: callback(mock_panel)
+
+@pytest.fixture
+async def processor_run(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+) -> AsyncGenerator[tuple[AquaLogicProcessor, MagicMock]]:
+ """Provide a real AquaLogicProcessor for testing run() without starting the thread."""
+ mock_config_entry.add_to_hass(hass)
+ with (
+ patch("homeassistant.components.aqualogic.RECONNECT_INTERVAL", timedelta(0)),
+ patch("homeassistant.components.aqualogic.AquaLogic") as mock_al,
+ patch("homeassistant.components.aqualogic.PLATFORMS", []),
+ patch("homeassistant.components.aqualogic.AquaLogicProcessor.start"),
+ ):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+ yield mock_config_entry.runtime_data, mock_al
diff --git a/tests/components/aqualogic/test_config_flow.py b/tests/components/aqualogic/test_config_flow.py
new file mode 100644
index 000000000000..1b75100d4189
--- /dev/null
+++ b/tests/components/aqualogic/test_config_flow.py
@@ -0,0 +1,191 @@
+"""Tests for the AquaLogic config flow."""
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from homeassistant.components.aqualogic.const import DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+ """Override async_setup_entry."""
+ with patch(
+ "homeassistant.components.aqualogic.async_setup_entry", return_value=True
+ ) as mock:
+ yield mock
+
+
+@pytest.mark.usefixtures("mock_aqualogic_device")
+async def test_user_flow_success(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test we get the form and create an entry."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "AquaLogic"
+ assert result["data"] == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}
+ mock_setup_entry.assert_called_once()
+
+
+async def test_user_flow_cannot_connect(
+ hass: HomeAssistant,
+ mock_aqualogic_device: MagicMock,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test we handle cannot connect error and allow retry."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ mock_aqualogic_device.return_value.connect.side_effect = OSError
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+ mock_aqualogic_device.return_value.connect.side_effect = None
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+
+
+async def test_user_flow_invalid_device(
+ hass: HomeAssistant,
+ mock_aqualogic_device: MagicMock,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test we handle a device that does not speak the AquaLogic protocol."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ mock_aqualogic_device.return_value.process.side_effect = None
+ with patch("homeassistant.components.aqualogic.config_flow._PROBE_TIMEOUT", 0):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": "invalid_device"}
+
+ def _fake_process(callback: object) -> None:
+ callback(mock_aqualogic_device.return_value)
+
+ mock_aqualogic_device.return_value.process.side_effect = _fake_process
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+
+
+async def test_user_flow_already_configured(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> None:
+ """Test we abort if the host/port is already configured."""
+ mock_config_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
+@pytest.mark.usefixtures("mock_aqualogic_device")
+async def test_import_flow_success(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test importing from configuration.yaml creates a config entry."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "AquaLogic"
+ assert result["data"] == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}
+ mock_setup_entry.assert_called_once()
+
+
+async def test_import_flow_cannot_connect(hass: HomeAssistant) -> None:
+ """Test we abort the import if we cannot connect."""
+ with patch(
+ "homeassistant.components.aqualogic.config_flow.AquaLogic"
+ ) as mock_al_class:
+ mock_al_class.return_value.connect.side_effect = OSError
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_import_flow_invalid_device(hass: HomeAssistant) -> None:
+ """Test we abort the import if the device does not speak the AquaLogic protocol."""
+ with (
+ patch("homeassistant.components.aqualogic.config_flow.AquaLogic"),
+ patch("homeassistant.components.aqualogic.config_flow._PROBE_TIMEOUT", 0),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_import_flow_already_configured(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> None:
+ """Test we abort the import if already configured."""
+ mock_config_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
diff --git a/tests/components/aqualogic/test_init.py b/tests/components/aqualogic/test_init.py
index 14725a70fa82..a4f57a153438 100644
--- a/tests/components/aqualogic/test_init.py
+++ b/tests/components/aqualogic/test_init.py
@@ -1,91 +1,145 @@
"""Tests for the AquaLogic integration setup."""
-from datetime import timedelta
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock
-from homeassistant.components.aqualogic import DOMAIN
-from homeassistant.const import (
- CONF_HOST,
- CONF_PORT,
- EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP,
-)
-from homeassistant.core import HomeAssistant
+import pytest
+
+from homeassistant.components.aqualogic import AquaLogicProcessor
+from homeassistant.components.aqualogic.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
+from tests.common import MockConfigEntry
-async def test_setup_creates_processor(
- hass: HomeAssistant, mock_processor: MagicMock
+
+async def test_load_unload_entry(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_processor: MagicMock,
) -> None:
- """Test setup registers the processor in hass.data."""
- assert await async_setup_component(
- hass,
- DOMAIN,
- {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}},
- )
+ """Test loading and unloading the config entry starts and stops the processor."""
+ mock_config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
- assert hass.data[DOMAIN] is mock_processor
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+ mock_processor.start.assert_called_once()
-
-async def test_processor_starts_on_ha_start(
- hass: HomeAssistant, mock_processor: MagicMock
-) -> None:
- """Test the processor thread starts when Home Assistant starts."""
- assert await async_setup_component(
- hass,
- DOMAIN,
- {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}},
- )
- await hass.async_block_till_done()
-
- hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
- await hass.async_block_till_done()
-
- mock_processor.start_listen.assert_called_once()
-
-
-async def test_processor_shuts_down_on_ha_stop(
- hass: HomeAssistant, mock_processor: MagicMock
-) -> None:
- """Test the processor shuts down when Home Assistant stops."""
- assert await async_setup_component(
- hass,
- DOMAIN,
- {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}},
- )
- await hass.async_block_till_done()
-
- hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ mock_processor.is_alive.return_value = False
+ assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
+ assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
mock_processor.shutdown.assert_called_once()
+ mock_processor.join.assert_called_once_with(timeout=5)
-async def test_processor_run_reconnects(hass: HomeAssistant) -> None:
- """Test the processor reconnects after a dropped connection."""
+@pytest.mark.usefixtures("mock_aqualogic_device")
+async def test_import_from_yaml(
+ hass: HomeAssistant,
+ mock_processor: MagicMock,
+ issue_registry: ir.IssueRegistry,
+) -> None:
+ """Test importing from YAML creates a config entry and a deprecation issue."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}},
)
await hass.async_block_till_done()
- processor = hass.data[DOMAIN]
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ assert entries[0].data == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}
+
+ issue = issue_registry.async_get_issue(
+ HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}"
+ )
+ assert issue is not None
+ assert issue.issue_domain == DOMAIN
+
+
+async def test_import_from_yaml_cannot_connect(
+ hass: HomeAssistant,
+ mock_processor: MagicMock,
+ mock_aqualogic_device: MagicMock,
+ issue_registry: ir.IssueRegistry,
+) -> None:
+ """Test a failed YAML import raises a specific issue instead of the migration notice."""
+ mock_aqualogic_device.return_value.connect.side_effect = OSError
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}},
+ )
+ await hass.async_block_till_done()
+
+ assert hass.config_entries.async_entries(DOMAIN) == []
+
+ assert (
+ issue_registry.async_get_issue(
+ HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}"
+ )
+ is None
+ )
+
+ issue = issue_registry.async_get_issue(
+ DOMAIN, "deprecated_yaml_import_issue_cannot_connect"
+ )
+ assert issue is not None
+ assert issue.issue_domain == DOMAIN
+
+
+async def test_processor_run_exits_on_shutdown(
+ processor_run: tuple[AquaLogicProcessor, MagicMock],
+) -> None:
+ """Test run() processes once then exits when shutdown is already set."""
+ processor, mock_al = processor_run
+ processor._shutdown = True
+ processor.run()
+ mock_al.return_value.connect.assert_called_once_with("1.2.3.4", 8899)
+
+
+async def test_processor_run_continues_after_unexpected_exception(
+ processor_run: tuple[AquaLogicProcessor, MagicMock],
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test the processor retries when process() raises an unexpected non-OSError exception."""
+ processor, mock_al = processor_run
connect_calls = 0
- def stop_on_second_connect(*args: object, **kwargs: object) -> None:
+ def stop_on_second_connect(*_args: object, **_kwargs: object) -> None:
nonlocal connect_calls
connect_calls += 1
if connect_calls >= 2:
processor._shutdown = True
- # Patch RECONNECT_INTERVAL to zero so time.sleep(0) returns immediately
- with (
- patch("homeassistant.components.aqualogic.RECONNECT_INTERVAL", timedelta(0)),
- patch("homeassistant.components.aqualogic.AquaLogic") as mock_al,
- ):
- mock_al.return_value.connect.side_effect = stop_on_second_connect
- processor.run()
+ mock_al.return_value.connect.side_effect = stop_on_second_connect
+ mock_al.return_value.process.side_effect = RuntimeError("unexpected error")
+ processor.run()
+
+ assert connect_calls == 2
+ assert "Unexpected error in AquaLogic processor: RuntimeError" in caplog.text
+
+
+async def test_processor_run_reconnects(
+ processor_run: tuple[AquaLogicProcessor, MagicMock],
+) -> None:
+ """Test the processor reconnects after a dropped connection."""
+ processor, mock_al = processor_run
+ connect_calls = 0
+
+ def stop_on_second_connect(*_args: object, **_kwargs: object) -> None:
+ nonlocal connect_calls
+ connect_calls += 1
+ if connect_calls >= 2:
+ processor._shutdown = True
+
+ mock_al.return_value.connect.side_effect = stop_on_second_connect
+ processor.run()
assert connect_calls == 2
diff --git a/tests/components/aqualogic/test_sensor.py b/tests/components/aqualogic/test_sensor.py
index 7f6981c51b4e..c4eb66853a38 100644
--- a/tests/components/aqualogic/test_sensor.py
+++ b/tests/components/aqualogic/test_sensor.py
@@ -1,37 +1,28 @@
"""Tests for the AquaLogic sensor platform."""
-from collections.abc import Callable
from unittest.mock import MagicMock
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor
+from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.setup import async_setup_component
@pytest.fixture
-async def init_sensors(
- hass: HomeAssistant, init_integration: AquaLogicProcessor
-) -> None:
- """Set up the AquaLogic sensor platform."""
- assert await async_setup_component(
- hass,
- "sensor",
- {"sensor": {"platform": DOMAIN}},
- )
- await hass.async_block_till_done()
+def platforms() -> list[Platform]:
+ """Fixture to specify platforms to test."""
+ return [Platform.SENSOR]
-@pytest.mark.usefixtures("init_sensors")
+@pytest.mark.usefixtures("init_integration")
async def test_sensors(
hass: HomeAssistant,
+ mock_processor: MagicMock,
snapshot: SnapshotAssertion,
- update_callback: Callable[[], None],
) -> None:
- """Test all sensor entities are created and report correct state."""
- update_callback()
+ """Test sensor entities are created and report correct state."""
+ mock_processor.data_changed(mock_processor.panel)
await hass.async_block_till_done()
states = {
@@ -41,16 +32,33 @@ async def test_sensors(
assert states == snapshot
-@pytest.mark.usefixtures("init_sensors")
+@pytest.mark.usefixtures("init_integration")
async def test_sensors_imperial_units(
hass: HomeAssistant,
- update_callback: Callable[[], None],
- mock_panel: MagicMock,
+ mock_processor: MagicMock,
) -> None:
"""Test sensors report imperial units when the panel is not metric."""
- mock_panel.is_metric = False
- update_callback()
+ mock_processor.panel.is_metric = False
+ mock_processor.data_changed(mock_processor.panel)
await hass.async_block_till_done()
+ # Use salt_level (g/L → PPM) to verify imperial branch without HA unit conversion
state = hass.states.get("sensor.aqualogic_salt_level")
assert state.attributes["unit_of_measurement"] == "PPM"
+
+
+@pytest.mark.usefixtures("init_integration")
+async def test_sensors_no_panel(
+ hass: HomeAssistant,
+ mock_processor: MagicMock,
+) -> None:
+ """Test sensors revert to unknown when the panel becomes unavailable."""
+ mock_processor.data_changed(mock_processor.panel)
+ await hass.async_block_till_done()
+ assert hass.states.get("sensor.aqualogic_air_temperature").state == "25.5"
+
+ mock_processor.panel = None
+ mock_processor.data_changed(None)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.aqualogic_air_temperature").state == STATE_UNKNOWN
diff --git a/tests/components/aqualogic/test_switch.py b/tests/components/aqualogic/test_switch.py
index a36ad59199f4..ed1bd7d53d54 100644
--- a/tests/components/aqualogic/test_switch.py
+++ b/tests/components/aqualogic/test_switch.py
@@ -6,32 +6,34 @@ from aqualogic.core import States
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor
+from homeassistant.components.aqualogic.const import UPDATE_TOPIC
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
-from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_OFF,
+ Platform,
+)
from homeassistant.core import HomeAssistant
-from homeassistant.setup import async_setup_component
+from homeassistant.helpers.dispatcher import async_dispatcher_send
@pytest.fixture
-async def init_switches(
- hass: HomeAssistant, init_integration: AquaLogicProcessor
-) -> None:
- """Set up the AquaLogic switch platform."""
- assert await async_setup_component(
- hass,
- "switch",
- {"switch": {"platform": DOMAIN}},
- )
- await hass.async_block_till_done()
+def platforms() -> list[Platform]:
+ """Fixture to specify platforms to test."""
+ return [Platform.SWITCH]
-@pytest.mark.usefixtures("init_switches")
+@pytest.mark.usefixtures("init_integration")
async def test_switches(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
) -> None:
- """Test all switch entities are created and report correct state."""
+ """Test switch entities are created and report correct state."""
+ async_dispatcher_send(hass, UPDATE_TOPIC)
+ await hass.async_block_till_done()
+
states = {
state.entity_id: state
for state in sorted(hass.states.async_all("switch"), key=lambda s: s.entity_id)
@@ -39,7 +41,7 @@ async def test_switches(
assert states == snapshot
-@pytest.mark.usefixtures("init_switches")
+@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("service", "expected_state"),
[
@@ -61,3 +63,41 @@ async def test_turn(
blocking=True,
)
mock_panel.set_state.assert_called_once_with(States.LIGHTS, expected_state)
+
+
+@pytest.mark.usefixtures("init_integration")
+@pytest.mark.parametrize(
+ "service",
+ [
+ pytest.param(SERVICE_TURN_ON, id="turn_on"),
+ pytest.param(SERVICE_TURN_OFF, id="turn_off"),
+ ],
+)
+async def test_turn_no_panel(
+ hass: HomeAssistant,
+ mock_processor: MagicMock,
+ service: str,
+) -> None:
+ """Test that turning a switch does nothing when the panel is unavailable."""
+ panel = mock_processor.panel
+ mock_processor.panel = None
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ service,
+ {ATTR_ENTITY_ID: "switch.aqualogic_lights"},
+ blocking=True,
+ )
+ panel.set_state.assert_not_called()
+
+
+@pytest.mark.usefixtures("init_integration")
+async def test_is_on_no_panel(
+ hass: HomeAssistant,
+ mock_processor: MagicMock,
+) -> None:
+ """Test switch reports off when panel is unavailable."""
+ mock_processor.panel = None
+ async_dispatcher_send(hass, UPDATE_TOPIC)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("switch.aqualogic_lights").state == STATE_OFF
diff --git a/tests/components/aqvify/snapshots/test_init.ambr b/tests/components/aqvify/snapshots/test_init.ambr
index fe3d2c055820..1cef36dab3f8 100644
--- a/tests/components/aqvify/snapshots/test_init.ambr
+++ b/tests/components/aqvify/snapshots/test_init.ambr
@@ -3,8 +3,8 @@
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'https://app.aqvify.com',
'connections': set({
}),
@@ -25,15 +25,14 @@
'model_id': None,
'name': 'Device 1',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': 'DeviceKey_1',
'sw_version': None,
'via_device_id': None,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'https://app.aqvify.com',
'connections': set({
}),
@@ -54,7 +53,6 @@
'model_id': None,
'name': 'Device 2',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': 'DeviceKey_2',
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py
index ff4430613080..3a85803d7219 100644
--- a/tests/components/assist_pipeline/test_pipeline.py
+++ b/tests/components/assist_pipeline/test_pipeline.py
@@ -40,6 +40,7 @@ from homeassistant.components.assist_pipeline.pipeline import (
async_get_pipelines,
async_update_pipeline,
)
+from homeassistant.components.llm import LLMTools
from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import (
@@ -1822,8 +1823,9 @@ async def test_chat_log_tts_streaming(
with (
patch(
- "homeassistant.helpers.llm.AssistAPI._async_get_tools",
- return_value=[mock_tool],
+ "homeassistant.components.llm.async_get_tools",
+ new_callable=AsyncMock,
+ return_value=LLMTools(tools=[mock_tool]),
),
patch(
"homeassistant.components.assist_pipeline.pipeline.conversation.async_converse",
diff --git a/tests/components/assist_satellite/test_llm.py b/tests/components/assist_satellite/test_llm.py
new file mode 100644
index 000000000000..9a65b35b9f8a
--- /dev/null
+++ b/tests/components/assist_satellite/test_llm.py
@@ -0,0 +1,34 @@
+"""Tests for the assist_satellite LLM tools platform."""
+
+import pytest
+
+from homeassistant.components import llm as llm_component
+from homeassistant.core import Context, HomeAssistant
+from homeassistant.helpers import llm
+from homeassistant.setup import async_setup_component
+
+
+@pytest.fixture(autouse=True)
+async def init_integrations(hass: HomeAssistant) -> None:
+ """Set up the integrations; assist_satellite registers the broadcast intent."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ assert await async_setup_component(hass, "intent", {})
+ assert await async_setup_component(hass, "llm", {})
+ assert await async_setup_component(hass, "assist_satellite", {})
+
+
+def _llm_context() -> llm.LLMContext:
+ """Return an LLM context for the conversation assistant."""
+ return llm.LLMContext(
+ platform="test_platform",
+ context=Context(),
+ language="*",
+ assistant="conversation",
+ device_id=None,
+ )
+
+
+async def test_broadcast_tool_offered(hass: HomeAssistant) -> None:
+ """Test the broadcast intent is exposed as an LLM tool."""
+ result = await llm_component.async_get_tools(hass, _llm_context(), "assist")
+ assert "HassBroadcast" in [tool.name for tool in result.tools]
diff --git a/tests/components/asuswrt/snapshots/test_init.ambr b/tests/components/asuswrt/snapshots/test_init.ambr
index 6b344d260c39..8f0901e3a5ad 100644
--- a/tests/components/asuswrt/snapshots/test_init.ambr
+++ b/tests/components/asuswrt/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'http://myrouter.asuswrt.com:80',
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': None,
'name': 'myrouter.asuswrt.com',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': 'FAKE_FIRMWARE',
'via_device_id': None,
diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr
index 9d94ae9ffdc6..456a4468708d 100644
--- a/tests/components/august/snapshots/test_binary_sensor.ambr
+++ b/tests/components/august/snapshots/test_binary_sensor.ambr
@@ -2,8 +2,8 @@
# name: test_doorbell_device_registry
DeviceRegistryEntrySnapshot({
'area_id': 'tmt100_name',
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'https://account.august.com',
'connections': set({
}),
@@ -24,7 +24,6 @@
'model_id': None,
'name': 'tmt100 Name',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': '3.1.0-HYDRC75+201909251139',
'via_device_id': None,
diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr
index 8af45cae68c8..a9f1292c9b43 100644
--- a/tests/components/august/snapshots/test_lock.ambr
+++ b/tests/components/august/snapshots/test_lock.ambr
@@ -2,8 +2,8 @@
# name: test_lock_device_registry
DeviceRegistryEntrySnapshot({
'area_id': 'online_with_doorsense_name',
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'https://account.august.com',
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': None,
'name': 'online_with_doorsense Name',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': 'undefined-4.3.0-1.8.14',
'via_device_id': None,
diff --git a/tests/components/aurora/__init__.py b/tests/components/aurora/__init__.py
index eca5281f6312..5be18fffb385 100644
--- a/tests/components/aurora/__init__.py
+++ b/tests/components/aurora/__init__.py
@@ -6,7 +6,7 @@ from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
- """Fixture for setting up the component."""
+ """Fixture for setting up the integration."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index 938af58b0f64..8347f066fadf 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -2481,6 +2481,7 @@ async def test_extraction_functions(
"sensor.trigger_state",
"sensor.trigger_numeric_state",
"sensor.trigger_event",
+ "light.bla",
"light.condition_state",
"light.in_both",
"light.in_first",
diff --git a/tests/components/autoskope/snapshots/test_device_tracker.ambr b/tests/components/autoskope/snapshots/test_device_tracker.ambr
index 8b66511a8d4f..686ff63b8efa 100644
--- a/tests/components/autoskope/snapshots/test_device_tracker.ambr
+++ b/tests/components/autoskope/snapshots/test_device_tracker.ambr
@@ -46,8 +46,8 @@
: 'mdi:car',
: list([
]),
- : 50.1109221,
- : 8.6821267,
+ : 50.1109221,
+ : 8.6821267,
: ,
: ,
}),
diff --git a/tests/components/aws_s3/snapshots/test_sensor.ambr b/tests/components/aws_s3/snapshots/test_sensor.ambr
index ed0d8379e987..09fb233a6af9 100644
--- a/tests/components/aws_s3/snapshots/test_sensor.ambr
+++ b/tests/components/aws_s3/snapshots/test_sensor.ambr
@@ -2,8 +2,8 @@
# name: test_sensor.2
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
}),
@@ -24,7 +24,6 @@
'model_id': None,
'name': 'Bucket test',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr
index 663c52dd36c4..20531509f452 100644
--- a/tests/components/axis/snapshots/test_hub.ambr
+++ b/tests/components/axis/snapshots/test_hub.ambr
@@ -2,8 +2,8 @@
# name: test_device_registry_entry[api_discovery_items0]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'http://1.2.3.4:80',
'connections': set({
tuple(
@@ -28,7 +28,6 @@
'model_id': None,
'name': 'home',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': '00:40:8c:12:34:56',
'sw_version': '9.10.1',
'via_device_id': None,
@@ -37,8 +36,8 @@
# name: test_device_registry_entry[api_discovery_items1]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'http://1.2.3.4:80',
'connections': set({
tuple(
@@ -63,7 +62,6 @@
'model_id': None,
'name': 'home',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': '00:40:8c:12:34:56',
'sw_version': '9.80.1',
'via_device_id': None,
diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py
index ea9585f0a746..1ba01a7ef02a 100644
--- a/tests/components/blebox/test_binary_sensor.py
+++ b/tests/components/blebox/test_binary_sensor.py
@@ -62,7 +62,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]:
product = feature.product
type(product).name = PropertyMock(return_value="My input sensor")
type(product).model = PropertyMock(return_value="inputSensorD")
- return feature, "binary_sensor.my_input_sensor"
+ return feature, "binary_sensor.my_input_sensor_input"
@pytest.mark.parametrize(
@@ -87,7 +87,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]:
pytest.param(
"inputsensor",
"BleBox-inputSensorD-aa11bb22cc33-0.input",
- "My input sensor",
+ "My input sensor Input",
None,
STATE_ON,
"My input sensor",
diff --git a/tests/components/blebox/test_button.py b/tests/components/blebox/test_button.py
index 6e9a5c3323bb..1ec63623141b 100644
--- a/tests/components/blebox/test_button.py
+++ b/tests/components/blebox/test_button.py
@@ -7,17 +7,16 @@ import blebox_uniapi
import pytest
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
from .conftest import async_setup_entity, mock_feature
query_translation_key_matching = [
- ("up", "up"),
- ("down", "down"),
- ("fav", "fav"),
- ("open", "open"),
- ("close", "close"),
- ("unknown_action", None),
+ ("up", "up", "button.my_tvliftbox_up", "My tvLiftBox Up"),
+ ("down", "down", "button.my_tvliftbox_down", "My tvLiftBox Down"),
+ ("fav", "fav", "button.my_tvliftbox_favorite", "My tvLiftBox Favorite"),
+ ("open", "open", "button.my_tvliftbox_open", "My tvLiftBox Open"),
+ ("close", "close", "button.my_tvliftbox_close", "My tvLiftBox Close"),
+ ("unknown_action", None, "button.my_tvliftbox", "My tvLiftBox"),
]
@@ -58,13 +57,15 @@ async def test_tvliftbox_init(
@pytest.mark.parametrize(
- ("query_string", "expected_translation_key"),
+ ("query_string", "expected_translation_key", "expected_entity_id", "expected_name"),
query_translation_key_matching,
ids=[q[0] for q in query_translation_key_matching],
)
async def test_button_translation_key(
query_string: str,
expected_translation_key: str | None,
+ expected_entity_id: str,
+ expected_name: str,
tvliftbox: tuple[blebox_uniapi.button.Button, str],
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@@ -72,13 +73,12 @@ async def test_button_translation_key(
"""Test that the correct translation_key is assigned based on query_string."""
caplog.set_level(logging.ERROR)
- feature_mock, entity_id = tvliftbox
+ feature_mock, _ = tvliftbox
feature_mock.query_string = query_string
- await async_setup_entity(hass, entity_id)
-
- state = hass.states.get(entity_id)
- assert state is not None
-
- entity = er.async_get(hass).async_get(entity_id)
+ entity = await async_setup_entity(hass, expected_entity_id)
assert entity is not None
assert entity.translation_key == expected_translation_key
+
+ state = hass.states.get(expected_entity_id)
+ assert state is not None
+ assert state.name == expected_name
diff --git a/tests/components/blebox/test_climate.py b/tests/components/blebox/test_climate.py
index 6aa7c02ebcbe..7d55d8447b09 100644
--- a/tests/components/blebox/test_climate.py
+++ b/tests/components/blebox/test_climate.py
@@ -135,128 +135,58 @@ async def test_update(saunabox, hass: HomeAssistant, config) -> None:
assert state.state == HVACMode.OFF
-async def test_on_when_below_desired(saunabox, hass: HomeAssistant) -> None:
- """Test when temperature is below desired."""
+async def test_set_hvac_mode_heat(saunabox, hass: HomeAssistant) -> None:
+ """Test that setting HVAC mode to heat calls async_on."""
feature_mock, entity_id = saunabox
- feature_mock.is_on = False
- await async_setup_entity(hass, entity_id)
-
- def turn_on():
- feature_mock.is_on = True
- feature_mock.is_heating = True
- feature_mock.desired = 64.8
- feature_mock.current = 25.7
-
feature_mock.mode = 1
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
- await hass.services.async_call(
- "climate",
- SERVICE_SET_HVAC_MODE,
- {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.HEAT},
- blocking=True,
- )
- feature_mock.async_off.assert_not_called()
- state = hass.states.get(entity_id)
-
- assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
- assert state.attributes[ATTR_TEMPERATURE] == 64.8
- assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.7
- assert state.state == HVACMode.HEAT
-
-
-async def test_on_when_above_desired(saunabox, hass: HomeAssistant) -> None:
- """Test when temperature is below desired."""
-
- feature_mock, entity_id = saunabox
-
- feature_mock.is_on = False
await async_setup_entity(hass, entity_id)
- def turn_on():
- feature_mock.is_on = True
- feature_mock.is_heating = False
- feature_mock.desired = 23.4
- feature_mock.current = 28.7
-
- feature_mock.mode = 1
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
-
await hass.services.async_call(
"climate",
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT},
blocking=True,
)
+
+ feature_mock.async_on.assert_called_once_with()
feature_mock.async_off.assert_not_called()
- state = hass.states.get(entity_id)
-
- assert state.attributes[ATTR_TEMPERATURE] == 23.4
- assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 28.7
- assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE
- assert state.state == HVACMode.HEAT
-async def test_off(saunabox, hass: HomeAssistant) -> None:
- """Test turning off."""
+async def test_set_hvac_mode_off(saunabox, hass: HomeAssistant) -> None:
+ """Test that setting HVAC mode to off calls async_off."""
feature_mock, entity_id = saunabox
- feature_mock.is_on = True
- feature_mock.is_heating = False
await async_setup_entity(hass, entity_id)
- def turn_off():
- feature_mock.is_on = False
- feature_mock.is_heating = False
- feature_mock.desired = 29.8
- feature_mock.current = 22.7
-
- feature_mock.async_off = AsyncMock(side_effect=turn_off)
await hass.services.async_call(
"climate",
SERVICE_SET_HVAC_MODE,
{"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.OFF},
blocking=True,
)
- feature_mock.async_on.assert_not_called()
- state = hass.states.get(entity_id)
- assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF
- assert state.attributes[ATTR_TEMPERATURE] == 29.8
- assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.7
- assert state.state == HVACMode.OFF
+ feature_mock.async_off.assert_called_once_with()
+ feature_mock.async_on.assert_not_called()
async def test_set_thermo(saunabox, hass: HomeAssistant) -> None:
- """Test setting thermostat."""
+ """Test that setting the temperature calls async_set_temperature."""
feature_mock, entity_id = saunabox
- feature_mock.is_on = False
- feature_mock.is_heating = False
await async_setup_entity(hass, entity_id)
- def set_temp(temp):
- feature_mock.is_on = True
- feature_mock.is_heating = True
- feature_mock.desired = 29.2
- feature_mock.current = 29.1
-
- feature_mock.async_set_temperature = AsyncMock(side_effect=set_temp)
await hass.services.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{"entity_id": entity_id, ATTR_TEMPERATURE: 43.21},
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.attributes[ATTR_TEMPERATURE] == 29.2
- assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 29.1
- assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
- assert state.state == HVACMode.HEAT
+ feature_mock.async_set_temperature.assert_called_once_with(43.21)
async def test_update_failure(
@@ -280,55 +210,30 @@ async def test_update_failure(
assert config_entry.state is ConfigEntryState.SETUP_RETRY
-async def test_reding_hvac_actions(
- saunabox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture
-) -> None:
- """Test hvac action for given device(mock) state."""
-
- caplog.set_level(logging.ERROR)
+async def test_hvac_action_heating(saunabox, hass: HomeAssistant) -> None:
+ """Test hvac_action reflects a heating device state."""
feature_mock, entity_id = saunabox
+
+ feature_mock.is_on = True
+ feature_mock.hvac_action = 1
+ feature_mock.mode = 1
await async_setup_entity(hass, entity_id)
- def set_temperature(temp):
- feature_mock.is_on = True
- feature_mock.hvac_action = 1
- feature_mock.mode = 1
-
- feature_mock.async_set_temperature = AsyncMock(side_effect=set_temperature)
-
- await hass.services.async_call(
- "climate",
- SERVICE_SET_TEMPERATURE,
- {"entity_id": entity_id, ATTR_TEMPERATURE: 43.21},
- blocking=True,
- )
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.HEAT]
-async def test_thermo_off(
- thermobox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture
-) -> None:
- """Test hvac action off fir given device state."""
- caplog.set_level(logging.ERROR)
+async def test_hvac_action_off(thermobox, hass: HomeAssistant) -> None:
+ """Test hvac_action reflects a device that is off."""
feature_mock, entity_id = thermobox
+
+ feature_mock.is_on = False
+ feature_mock.hvac_action = 0
await async_setup_entity(hass, entity_id)
- def set_off():
- feature_mock.is_on = False
- feature_mock.hvac_action = 0
-
- feature_mock.async_off = AsyncMock(side_effect=set_off)
-
- await hass.services.async_call(
- "climate",
- SERVICE_SET_HVAC_MODE,
- {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.OFF},
- blocking=True,
- )
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF
assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.COOL]
diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py
index 56a3bec3e293..b057e178d56d 100644
--- a/tests/components/blebox/test_cover.py
+++ b/tests/components/blebox/test_cover.py
@@ -259,21 +259,16 @@ async def test_open(feature, hass: HomeAssistant) -> None:
feature_mock, entity_id = feature
- feature_mock.state = 3 # manually stopped
await async_setup_entity(hass, entity_id)
- assert hass.states.get(entity_id).state == CoverState.CLOSED
- def open_gate():
- feature_mock.state = 1 # opening
-
- feature_mock.async_open = AsyncMock(side_effect=open_gate)
await hass.services.async_call(
"cover",
SERVICE_OPEN_COVER,
{"entity_id": entity_id},
blocking=True,
)
- assert hass.states.get(entity_id).state == CoverState.OPENING
+
+ feature_mock.async_open.assert_called_once_with()
@pytest.mark.parametrize("feature", ALL_COVER_FIXTURES, indirect=["feature"])
@@ -282,18 +277,13 @@ async def test_close(feature, hass: HomeAssistant) -> None:
feature_mock, entity_id = feature
- feature_mock.state = 4 # open
await async_setup_entity(hass, entity_id)
- assert hass.states.get(entity_id).state == CoverState.OPEN
- def close():
- feature_mock.state = 0 # closing
-
- feature_mock.async_close = AsyncMock(side_effect=close)
await hass.services.async_call(
"cover", SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True
)
- assert hass.states.get(entity_id).state == CoverState.CLOSING
+
+ feature_mock.async_close.assert_called_once_with()
@pytest.mark.parametrize("feature", FIXTURES_SUPPORTING_STOP, indirect=["feature"])
@@ -302,18 +292,13 @@ async def test_stop(feature, hass: HomeAssistant) -> None:
feature_mock, entity_id = feature
- feature_mock.state = 1 # opening
await async_setup_entity(hass, entity_id)
- assert hass.states.get(entity_id).state == CoverState.OPENING
- def stop():
- feature_mock.state = 2 # manually stopped
-
- feature_mock.async_stop = AsyncMock(side_effect=stop)
await hass.services.async_call(
"cover", SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True
)
- assert hass.states.get(entity_id).state == CoverState.OPEN
+
+ feature_mock.async_stop.assert_called_once_with()
@pytest.mark.parametrize(
@@ -355,23 +340,16 @@ async def test_set_position(feature, hass: HomeAssistant) -> None:
feature_mock, entity_id = feature
- feature_mock.state = 3 # closed
await async_setup_entity(hass, entity_id)
- assert hass.states.get(entity_id).state == CoverState.CLOSED
- def set_position(position):
- assert position == 99 # inverted
- feature_mock.state = 1 # opening
- # feature_mock.current = position
-
- feature_mock.async_set_position = AsyncMock(side_effect=set_position)
await hass.services.async_call(
"cover",
SERVICE_SET_COVER_POSITION,
{"entity_id": entity_id, ATTR_POSITION: 1},
blocking=True,
) # almost closed
- assert hass.states.get(entity_id).state == CoverState.OPENING
+
+ feature_mock.async_set_position.assert_called_once_with(99) # inverted
async def test_unknown_position(shutterbox, hass: HomeAssistant) -> None:
@@ -540,29 +518,23 @@ async def test_set_tilt_position(shutterbox, hass: HomeAssistant) -> None:
feature_mock, entity_id = shutterbox
- feature_mock.state = 3
await async_setup_entity(hass, entity_id)
- assert hass.states.get(entity_id).state == CoverState.CLOSED
- def set_tilt(tilt_position):
- assert tilt_position == 20
- feature_mock.state = 1
-
- feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt)
await hass.services.async_call(
"cover",
SERVICE_SET_COVER_TILT_POSITION,
{"entity_id": entity_id, ATTR_TILT_POSITION: 80},
blocking=True,
)
- assert hass.states.get(entity_id).state == CoverState.OPENING
+
+ feature_mock.async_set_tilt_position.assert_called_once_with(20)
@pytest.mark.parametrize(
- ("is_tilt_180", "expected_tilt_position", "expected_tilt_reported"),
+ ("is_tilt_180", "expected_tilt_position"),
[
- pytest.param(False, 0, 100, id="tilt_90"),
- pytest.param(True, 50, 50, id="tilt_180"),
+ pytest.param(False, 0, id="tilt_90"),
+ pytest.param(True, 50, id="tilt_180"),
],
)
async def test_open_tilt(
@@ -570,7 +542,6 @@ async def test_open_tilt(
hass: HomeAssistant,
is_tilt_180: bool,
expected_tilt_position: int,
- expected_tilt_reported: int,
) -> None:
"""Test opening tilt for 90-degree and 180-degree tilt shutters."""
feature_mock, entity_id = shutterbox
@@ -578,42 +549,27 @@ async def test_open_tilt(
feature_mock.tilt_current = 100
await async_setup_entity(hass, entity_id)
- def set_tilt_position(tilt_position):
- assert tilt_position == expected_tilt_position
- feature_mock.tilt_current = tilt_position
-
- feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt_position)
-
await hass.services.async_call(
"cover",
SERVICE_OPEN_COVER_TILT,
{"entity_id": entity_id},
blocking=True,
)
- state = hass.states.get(entity_id)
- assert (
- state.attributes[ATTR_CURRENT_TILT_POSITION] == expected_tilt_reported
- ) # inverted
+
+ feature_mock.async_set_tilt_position.assert_called_once_with(expected_tilt_position)
async def test_close_tilt(shutterbox, hass: HomeAssistant) -> None:
"""Test closing tilt."""
feature_mock, entity_id = shutterbox
- feature_mock.tilt_current = 0
await async_setup_entity(hass, entity_id)
- def set_tilt_position(tilt_position):
- assert tilt_position == 100
- feature_mock.tilt_current = tilt_position
-
- feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt_position)
-
await hass.services.async_call(
"cover",
SERVICE_CLOSE_COVER_TILT,
{"entity_id": entity_id},
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # inverted
+
+ feature_mock.async_set_tilt_position.assert_called_once_with(100)
diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py
index 8d0bdd74291a..031f4bca861a 100644
--- a/tests/components/blebox/test_light.py
+++ b/tests/components/blebox/test_light.py
@@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock
import blebox_uniapi
import pytest
-from homeassistant.components.blebox.const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@@ -19,7 +18,6 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
- STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
)
@@ -103,20 +101,9 @@ async def test_dimmer_on(dimmer, hass: HomeAssistant) -> None:
feature_mock, entity_id = dimmer
- feature_mock.is_on = False
- feature_mock.brightness = 0 # off
feature_mock.sensible_on_value = 254
await async_setup_entity(hass, entity_id)
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
-
- def turn_on(brightness):
- assert brightness == 254
- feature_mock.brightness = 254 # on
- feature_mock.is_on = True # on
-
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
@@ -124,9 +111,7 @@ async def test_dimmer_on(dimmer, hass: HomeAssistant) -> None:
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.state == STATE_ON
- assert state.attributes[ATTR_BRIGHTNESS] == 254
+ feature_mock.async_on.assert_called_once_with(254)
async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None:
@@ -134,21 +119,9 @@ async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None:
feature_mock, entity_id = dimmer
- feature_mock.is_on = False
- feature_mock.brightness = 0 # off
feature_mock.sensible_on_value = 254
await async_setup_entity(hass, entity_id)
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
-
- def turn_on(brightness):
- assert brightness == 202
- feature_mock.brightness = 202 # on
- feature_mock.is_on = True # on
-
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
-
def apply(value, brightness):
assert value == 254
return brightness
@@ -161,9 +134,7 @@ async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None:
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.attributes[ATTR_BRIGHTNESS] == 202
- assert state.state == STATE_ON
+ feature_mock.async_on.assert_called_once_with(202)
async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None:
@@ -171,17 +142,8 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None:
feature_mock, entity_id = dimmer
- feature_mock.is_on = True
await async_setup_entity(hass, entity_id)
- state = hass.states.get(entity_id)
- assert state.state == STATE_ON
-
- def turn_off():
- feature_mock.is_on = False
- feature_mock.brightness = 0 # off
-
- feature_mock.async_off = AsyncMock(side_effect=turn_off)
await hass.services.async_call(
"light",
SERVICE_TURN_OFF,
@@ -189,9 +151,7 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None:
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
- assert state.attributes[ATTR_BRIGHTNESS] is None
+ feature_mock.async_off.assert_called_once_with()
@pytest.fixture(name="wlightbox_s")
@@ -264,19 +224,9 @@ async def test_wlightbox_s_on(wlightbox_s, hass: HomeAssistant) -> None:
feature_mock, entity_id = wlightbox_s
- feature_mock.is_on = False
feature_mock.sensible_on_value = 254
await async_setup_entity(hass, entity_id)
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
-
- def turn_on(brightness):
- assert brightness == 254
- feature_mock.brightness = 254 # on
- feature_mock.is_on = True # on
-
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
@@ -284,9 +234,7 @@ async def test_wlightbox_s_on(wlightbox_s, hass: HomeAssistant) -> None:
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.attributes[ATTR_BRIGHTNESS] == 254
- assert state.state == STATE_ON
+ feature_mock.async_on.assert_called_once_with(254)
@pytest.fixture(name="wlightbox")
@@ -360,12 +308,7 @@ async def test_wlightbox_on_color_temp(
transient_temp = value
return [0x00, 0x39, 0xB0, 0xFF]
- def turn_on(_: list[int]) -> None:
- feature_mock.is_on = True
- feature_mock.color_temp = transient_temp
-
feature_mock.return_color_temp_with_brightness = return_color_temp_with_brightness
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
await async_setup_entity(hass, entity_id)
await hass.services.async_call(
@@ -375,12 +318,8 @@ async def test_wlightbox_on_color_temp(
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.state == STATE_ON
assert 0 <= transient_temp <= 255
-
- kelvin_actual = state.attributes[ATTR_COLOR_TEMP_KELVIN]
- assert LIGHT_MIN_KELVINS <= kelvin_actual <= LIGHT_MAX_KELVINS
+ feature_mock.async_on.assert_called_once_with([0x00, 0x39, 0xB0, 0xFF])
async def test_wlightbox_init(
@@ -431,35 +370,8 @@ async def test_wlightbox_on_rgbw(wlightbox, hass: HomeAssistant) -> None:
feature_mock, entity_id = wlightbox
- feature_mock.is_on = False
await async_setup_entity(hass, entity_id)
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
-
- def turn_on(value):
- feature_mock.is_on = True
- assert value == [193, 210, 243, 199]
- feature_mock.white_value = 0xC7 # on
- feature_mock.rgbw_hex = "c1d2f3c7"
-
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
-
- def apply_white(value, white):
- assert value == "00010203"
- assert white == 0xC7
- return "000102c7"
-
- feature_mock.apply_white = apply_white
-
- def apply_color(value, color_value):
- assert value == "000102c7"
- assert color_value == "c1d2f3"
- return "c1d2f3c7"
-
- feature_mock.apply_color = apply_color
- feature_mock.sensible_on_value = "00010203"
-
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
@@ -467,9 +379,7 @@ async def test_wlightbox_on_rgbw(wlightbox, hass: HomeAssistant) -> None:
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.state == STATE_ON
- assert state.attributes[ATTR_RGBW_COLOR] == (0xC1, 0xD2, 0xF3, 0xC7)
+ feature_mock.async_on.assert_called_once_with([193, 210, 243, 199])
async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> None:
@@ -477,20 +387,8 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non
feature_mock, entity_id = wlightbox
- feature_mock.is_on = False
- await async_setup_entity(hass, entity_id)
-
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
-
- def turn_on(value):
- feature_mock.is_on = True
- assert value == "f1e2d3e4"
- feature_mock.white_value = 0xE4
- feature_mock.rgbw_hex = value
-
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
feature_mock.sensible_on_value = "f1e2d3e4"
+ await async_setup_entity(hass, entity_id)
await hass.services.async_call(
"light",
@@ -499,9 +397,7 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.attributes[ATTR_RGBW_COLOR] == (0xF1, 0xE2, 0xD3, 0xE4)
- assert state.state == STATE_ON
+ feature_mock.async_on.assert_called_once_with("f1e2d3e4")
async def test_wlightbox_turn_on_with_zero_brightness_turns_off(
@@ -511,23 +407,8 @@ async def test_wlightbox_turn_on_with_zero_brightness_turns_off(
feature_mock, entity_id = wlightbox
- feature_mock.is_on = True
- feature_mock.rgbw_hex = "c1d2f3c7"
- feature_mock.white_value = 0xC7
await async_setup_entity(hass, entity_id)
- state = hass.states.get(entity_id)
- assert state.state == STATE_ON
-
- feature_mock.apply_brightness = MagicMock(return_value=[0, 0, 0, 0])
-
- def turn_off():
- feature_mock.is_on = False
- feature_mock.white_value = 0x0
- feature_mock.rgbw_hex = "00000000"
-
- feature_mock.async_off = AsyncMock(side_effect=turn_off)
-
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
@@ -535,31 +416,17 @@ async def test_wlightbox_turn_on_with_zero_brightness_turns_off(
blocking=True,
)
- feature_mock.async_off.assert_called_once()
+ feature_mock.async_off.assert_called_once_with()
feature_mock.async_on.assert_not_called()
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
-
async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None:
"""Test light off."""
feature_mock, entity_id = wlightbox
- feature_mock.is_on = True
await async_setup_entity(hass, entity_id)
- state = hass.states.get(entity_id)
- assert state.state == STATE_ON
-
- def turn_off():
- feature_mock.is_on = False
- feature_mock.white_value = 0x0
- feature_mock.rgbw_hex = "00000000"
-
- feature_mock.async_off = AsyncMock(side_effect=turn_off)
-
await hass.services.async_call(
"light",
SERVICE_TURN_OFF,
@@ -567,9 +434,7 @@ async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None:
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.attributes[ATTR_RGBW_COLOR] is None
- assert state.state == STATE_OFF
+ feature_mock.async_off.assert_called_once_with()
@pytest.mark.parametrize("feature", ALL_LIGHT_FIXTURES, indirect=["feature"])
@@ -623,18 +488,8 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None:
feature_mock, entity_id = wlightbox
- feature_mock.is_on = False
await async_setup_entity(hass, entity_id)
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
-
- def turn_on(value):
- feature_mock.is_on = True
- feature_mock.effect = "POLICE"
-
- feature_mock.async_on = AsyncMock(side_effect=turn_on)
-
with pytest.raises(HomeAssistantError) as info:
await hass.services.async_call(
"light",
@@ -644,6 +499,7 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None:
)
assert info.value.translation_key == "effect_not_found"
+ feature_mock.async_api_command.assert_not_called()
await hass.services.async_call(
"light",
@@ -652,8 +508,7 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None:
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.attributes[ATTR_EFFECT] == "POLICE"
+ feature_mock.async_api_command.assert_called_once_with("effect", 2)
@pytest.mark.parametrize(
diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py
index 45d1d9dc1675..5a24da079bcf 100644
--- a/tests/components/blebox/test_sensor.py
+++ b/tests/components/blebox/test_sensor.py
@@ -304,10 +304,7 @@ async def test_open_status_sensor_none_value(
"""Test that a None native_value yields an unknown state."""
feature_mock, entity_id = open_status_sensor
- def set_none():
- feature_mock.native_value = None
-
- feature_mock.async_update = AsyncMock(side_effect=set_none)
+ feature_mock.native_value = None
await async_setup_entity(hass, entity_id)
state = hass.states.get(entity_id)
@@ -380,10 +377,7 @@ async def test_co2_definition_sensor_none_value(
"""Test that a None native_value yields an unknown state."""
feature_mock, entity_id = co2_definition_sensor
- def set_none():
- feature_mock.native_value = None
-
- feature_mock.async_update = AsyncMock(side_effect=set_none)
+ feature_mock.native_value = None
await async_setup_entity(hass, entity_id)
state = hass.states.get(entity_id)
diff --git a/tests/components/blebox/test_switch.py b/tests/components/blebox/test_switch.py
index 959e68b43e2f..ccd66fad439e 100644
--- a/tests/components/blebox/test_switch.py
+++ b/tests/components/blebox/test_switch.py
@@ -106,14 +106,8 @@ async def test_switchbox_on(switchbox, hass: HomeAssistant) -> None:
feature_mock, entity_id = switchbox
- feature_mock.is_on = False
await async_setup_entity(hass, entity_id)
- def turn_on():
- feature_mock.is_on = True
-
- feature_mock.async_turn_on = AsyncMock(side_effect=turn_on)
-
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
@@ -121,8 +115,7 @@ async def test_switchbox_on(switchbox, hass: HomeAssistant) -> None:
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.state == STATE_ON
+ feature_mock.async_turn_on.assert_called_once_with()
async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None:
@@ -130,22 +123,16 @@ async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None:
feature_mock, entity_id = switchbox
- feature_mock.is_on = True
await async_setup_entity(hass, entity_id)
- def turn_off():
- feature_mock.is_on = False
-
- feature_mock.async_turn_off = AsyncMock(side_effect=turn_off)
-
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
{"entity_id": entity_id},
blocking=True,
)
- state = hass.states.get(entity_id)
- assert state.state == STATE_OFF
+
+ feature_mock.async_turn_off.assert_called_once_with()
def relay_mock(relay_id=0):
@@ -263,14 +250,8 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass: HomeAssistant) -> No
feature_mocks, entity_ids = switchbox_d
- feature_mocks[0].is_on = False
- feature_mocks[1].is_on = False
await async_setup_entities(hass, entity_ids)
- def turn_on0():
- feature_mocks[0].is_on = True
-
- feature_mocks[0].async_turn_on = AsyncMock(side_effect=turn_on0)
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
@@ -278,8 +259,8 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass: HomeAssistant) -> No
blocking=True,
)
- assert hass.states.get(entity_ids[0]).state == STATE_ON
- assert hass.states.get(entity_ids[1]).state == STATE_OFF
+ feature_mocks[0].async_turn_on.assert_called_once_with()
+ feature_mocks[1].async_turn_on.assert_not_called()
async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None:
@@ -287,14 +268,8 @@ async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None:
feature_mocks, entity_ids = switchbox_d
- feature_mocks[0].is_on = False
- feature_mocks[1].is_on = False
await async_setup_entities(hass, entity_ids)
- def turn_on1():
- feature_mocks[1].is_on = True
-
- feature_mocks[1].async_turn_on = AsyncMock(side_effect=turn_on1)
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
@@ -302,8 +277,8 @@ async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None:
blocking=True,
)
- assert hass.states.get(entity_ids[0]).state == STATE_OFF
- assert hass.states.get(entity_ids[1]).state == STATE_ON
+ feature_mocks[0].async_turn_on.assert_not_called()
+ feature_mocks[1].async_turn_on.assert_called_once_with()
async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None:
@@ -311,14 +286,8 @@ async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None:
feature_mocks, entity_ids = switchbox_d
- feature_mocks[0].is_on = True
- feature_mocks[1].is_on = True
await async_setup_entities(hass, entity_ids)
- def turn_off0():
- feature_mocks[0].is_on = False
-
- feature_mocks[0].async_turn_off = AsyncMock(side_effect=turn_off0)
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
@@ -326,8 +295,8 @@ async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None:
blocking=True,
)
- assert hass.states.get(entity_ids[0]).state == STATE_OFF
- assert hass.states.get(entity_ids[1]).state == STATE_ON
+ feature_mocks[0].async_turn_off.assert_called_once_with()
+ feature_mocks[1].async_turn_off.assert_not_called()
async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None:
@@ -335,22 +304,17 @@ async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None:
feature_mocks, entity_ids = switchbox_d
- feature_mocks[0].is_on = True
- feature_mocks[1].is_on = True
await async_setup_entities(hass, entity_ids)
- def turn_off1():
- feature_mocks[1].is_on = False
-
- feature_mocks[1].async_turn_off = AsyncMock(side_effect=turn_off1)
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
{"entity_id": entity_ids[1]},
blocking=True,
)
- assert hass.states.get(entity_ids[0]).state == STATE_ON
- assert hass.states.get(entity_ids[1]).state == STATE_OFF
+
+ feature_mocks[0].async_turn_off.assert_not_called()
+ feature_mocks[1].async_turn_off.assert_called_once_with()
async def test_switchbox_with_name(hass: HomeAssistant) -> None:
diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py
index b6f4b1f8ce1e..c8aa6610df5f 100644
--- a/tests/components/bluetooth/test_manager.py
+++ b/tests/components/bluetooth/test_manager.py
@@ -12,6 +12,9 @@ from habluetooth import BluetoothScanningMode, HaScanner
# pylint: disable-next=no-name-in-module
from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS
+
+# pylint: disable-next=no-name-in-module
+from habluetooth.const import STALE_ROAM_FACTOR
import pytest
from homeassistant import config_entries
@@ -402,9 +405,26 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval(
start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1,
HCI1_SOURCE_ADDRESS,
)
- # Should switch to hci1 since the previous advertisement is stale
- # even though the signal is poor because the device is now
- # likely unreachable via hci0
+ # Should not roam yet: a single missed reception interval must not hand a
+ # stationary device to a comparable scanner before the roam gate
+ # (STALE_ROAM_FACTOR stale windows)
+ assert (
+ bluetooth.async_ble_device_from_address(hass, address)
+ is switchbot_device_poor_signal_hci0
+ )
+
+ inject_advertisement_with_time_and_source(
+ hass,
+ switchbot_device_poor_signal_hci1,
+ switchbot_adv_poor_signal_hci1,
+ start_time_monotonic
+ + (10 + TRACKER_BUFFERING_WOBBLE_SECONDS) * STALE_ROAM_FACTOR
+ + 1,
+ HCI1_SOURCE_ADDRESS,
+ )
+ # Past the roam gate (STALE_ROAM_FACTOR stale windows): switch to hci1
+ # since the previous advertisement is stale even though the signal is poor
+ # because the device is now likely unreachable via hci0
assert (
bluetooth.async_ble_device_from_address(hass, address)
is switchbot_device_poor_signal_hci1
diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py
index a55bf63f2270..fd61edf8d3dd 100644
--- a/tests/components/broadlink/test_remote.py
+++ b/tests/components/broadlink/test_remote.py
@@ -3,19 +3,32 @@
from base64 import b64decode
from unittest.mock import call
+from broadlink.exceptions import BroadlinkException
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+
from homeassistant.components.broadlink.const import DOMAIN
+from homeassistant.components.broadlink.updater import BroadlinkRMUpdateManager
from homeassistant.components.remote import (
DOMAIN as REMOTE_DOMAIN,
SERVICE_SEND_COMMAND,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
-from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON, Platform
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME,
+ STATE_OFF,
+ STATE_ON,
+ STATE_UNAVAILABLE,
+ Platform,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import get_device
+from tests.common import async_fire_time_changed
+
REMOTE_DEVICES = ["Entrance", "Living Room", "Office", "Garage"]
IR_PACKET = (
@@ -78,6 +91,60 @@ async def test_remote_send_command(
assert mock_setup.api.auth.call_count == 1
+@pytest.mark.parametrize(
+ ("error", "ticks_to_unavailable"),
+ [
+ # OSError flips availability on the first failure (fast path).
+ (OSError("connection refused"), 1),
+ # A generic BroadlinkException keeps the entity available across the
+ # first three failed cycles and only flips once SCAN_INTERVAL * 3 has
+ # elapsed since the last successful update.
+ (BroadlinkException("update failed"), 4),
+ ],
+)
+async def test_remote_availability(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+ error: Exception,
+ ticks_to_unavailable: int,
+) -> None:
+ """Test the remote becomes unavailable on disconnect and recovers on reconnect."""
+ device = get_device("Garage")
+ mock_setup = await device.setup_entry(hass)
+
+ device_entry = device_registry.async_get_device(
+ identifiers={(DOMAIN, mock_setup.entry.unique_id)}
+ )
+ entries = er.async_entries_for_device(entity_registry, device_entry.id)
+ remote = next(entry for entry in entries if entry.domain == Platform.REMOTE)
+
+ assert hass.states.get(remote.entity_id).state == STATE_ON
+
+ mock_setup.api.check_sensors.side_effect = error
+
+ for _ in range(ticks_to_unavailable - 1):
+ freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+ assert hass.states.get(remote.entity_id).state == STATE_ON
+
+ freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(remote.entity_id).state == STATE_UNAVAILABLE
+
+ mock_setup.api.check_sensors.side_effect = None
+
+ freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(remote.entity_id).state == STATE_ON
+
+
async def test_remote_turn_off_turn_on(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr
index 5ff351d5dbf8..d76c41d35864 100644
--- a/tests/components/bsblan/snapshots/test_diagnostics.ambr
+++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr
@@ -126,6 +126,7 @@
}),
'states': dict({
'1': dict({
+ 'cooling_operating_mode': None,
'current_temperature': dict({
'data_type': 0,
'data_type_family': '',
diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py
index 7cff47e68598..e0fe8c1d5dbb 100644
--- a/tests/components/bsblan/test_climate.py
+++ b/tests/components/bsblan/test_climate.py
@@ -1,9 +1,10 @@
"""Tests for the BSB-LAN climate platform."""
from datetime import timedelta
+from typing import Any
from unittest.mock import AsyncMock, MagicMock
-from bsblan import BSBLANError, HeatingCircuitStatus
+from bsblan import BSBLANError, HeatingCircuitStatus, StaticState
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -12,6 +13,8 @@ from homeassistant.components.bsblan.const import DOMAIN
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
+ DEFAULT_MAX_TEMP,
+ DEFAULT_MIN_TEMP,
DOMAIN as CLIMATE_DOMAIN,
PRESET_ECO,
PRESET_NONE,
@@ -33,6 +36,81 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat
ENTITY_ID = "climate.heating_circuit_1"
+def _temp_param(value: str) -> dict[str, Any]:
+ """Build a raw BSB-LAN temperature parameter payload."""
+ return {
+ "name": "",
+ "value": value,
+ "unit": "°C",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "error": 0,
+ }
+
+
+@pytest.mark.parametrize(
+ ("static_data", "expected_min", "expected_max"),
+ [
+ pytest.param(
+ {
+ "heating_protective_setpoint": _temp_param("10.0"),
+ "comfort_setpoint_max": _temp_param("26.0"),
+ },
+ 10.0,
+ 26.0,
+ id="standard_device",
+ ),
+ pytest.param(
+ {
+ "min_temp": _temp_param("8.0"),
+ "max_temp": _temp_param("20.0"),
+ },
+ 8.0,
+ 20.0,
+ id="pps_device",
+ ),
+ pytest.param(
+ {
+ "heating_protective_setpoint": _temp_param("---"),
+ "comfort_setpoint_max": _temp_param("---"),
+ "min_temp": _temp_param("8.0"),
+ "max_temp": _temp_param("20.0"),
+ },
+ 8.0,
+ 20.0,
+ id="inactive_preferred_source",
+ ),
+ pytest.param(
+ {
+ "heating_protective_setpoint": _temp_param("---"),
+ "comfort_setpoint_max": _temp_param("---"),
+ },
+ DEFAULT_MIN_TEMP,
+ DEFAULT_MAX_TEMP,
+ id="all_sources_inactive",
+ ),
+ ],
+)
+async def test_climate_min_max_temperature(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ static_data: dict[str, Any],
+ expected_min: float,
+ expected_max: float,
+) -> None:
+ """Test min/max temperature bounds resolved from per-circuit static values."""
+ mock_bsblan.static_values.return_value = StaticState.model_validate(static_data)
+
+ await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
+
+ state = hass.states.get(ENTITY_ID)
+ assert state is not None
+ assert state.attributes["min_temp"] == expected_min
+ assert state.attributes["max_temp"] == expected_max
+
+
async def test_celsius_fahrenheit(
hass: HomeAssistant,
mock_bsblan: AsyncMock,
diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py
index 9ec11356c596..2b1fac2eef83 100644
--- a/tests/components/bsblan/test_init.py
+++ b/tests/components/bsblan/test_init.py
@@ -1,5 +1,6 @@
"""Tests for the BSBLan integration."""
+import asyncio
from datetime import timedelta
from unittest.mock import MagicMock
@@ -291,6 +292,37 @@ async def test_coordinator_dhw_config_update_error(
assert mock_bsblan.hot_water_schedule.called
+async def test_setup_does_not_block_on_slow_fetch(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_bsblan: MagicMock,
+) -> None:
+ """Test setup does not wait for the background slow-data fetch."""
+ release = asyncio.Event()
+ config_value = mock_bsblan.hot_water_config.return_value
+
+ async def _blocking_config(*args: object, **kwargs: object) -> object:
+ await release.wait()
+ return config_value
+
+ mock_bsblan.hot_water_config.side_effect = _blocking_config
+
+ mock_config_entry.add_to_hass(hass)
+ try:
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Setup finished even though the slow-data fetch is still pending.
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+ assert not mock_bsblan.hot_water_schedule.called
+ finally:
+ # Release the fetch so it can complete and clean up.
+ release.set()
+ await hass.async_block_till_done()
+
+ assert mock_bsblan.hot_water_schedule.called
+
+
async def test_coordinator_slow_first_fetch_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
diff --git a/tests/components/bsblan/test_services.py b/tests/components/bsblan/test_services.py
index 62f26c97f076..859484bcd21a 100644
--- a/tests/components/bsblan/test_services.py
+++ b/tests/components/bsblan/test_services.py
@@ -44,6 +44,19 @@ def device_entry(
return device
+@pytest.fixture
+def water_heater_device_entry(
+ device_registry: dr.DeviceRegistry,
+ setup_integration: None,
+) -> dr.DeviceEntry:
+ """Get the water heater sub-device entry for testing."""
+ device = device_registry.async_get_device(
+ identifiers={(DOMAIN, f"{TEST_DEVICE_MAC}-water-heater")}
+ )
+ assert device is not None
+ return device
+
+
@pytest.mark.usefixtures("setup_integration")
@pytest.mark.parametrize(
("service_data", "expected_schedules"),
@@ -119,13 +132,13 @@ def device_entry(
async def test_set_hot_water_schedule(
hass: HomeAssistant,
mock_bsblan: MagicMock,
- device_entry: dr.DeviceEntry,
+ water_heater_device_entry: dr.DeviceEntry,
service_data: dict[str, Any],
expected_schedules: dict[str, DaySchedule],
) -> None:
"""Test setting hot water schedule with various configurations."""
# Call the service with device_id and slot fields
- service_call_data = {"device_id": device_entry.id}
+ service_call_data = {"device_id": water_heater_device_entry.id}
service_call_data.update(service_data)
await hass.services.async_call(
@@ -216,7 +229,7 @@ async def test_no_config_entry_for_device(
async def test_config_entry_not_loaded(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
- device_entry: dr.DeviceEntry,
+ water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test error when config entry is not loaded."""
await hass.config_entries.async_unload(mock_config_entry.entry_id)
@@ -226,7 +239,7 @@ async def test_config_entry_not_loaded(
DOMAIN,
"set_hot_water_schedule",
{
- "device_id": device_entry.id,
+ "device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
@@ -241,12 +254,34 @@ async def test_config_entry_not_loaded(
async def test_api_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
- device_entry: dr.DeviceEntry,
+ water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test error when BSB-LAN API call fails."""
mock_bsblan.set_hot_water_schedule.side_effect = BSBLANError("API Error")
with pytest.raises(HomeAssistantError) as exc_info:
+ await hass.services.async_call(
+ DOMAIN,
+ "set_hot_water_schedule",
+ {
+ "device_id": water_heater_device_entry.id,
+ "monday_slots": [
+ {"start_time": time(6, 0), "end_time": time(8, 0)},
+ ],
+ },
+ blocking=True,
+ )
+
+ assert exc_info.value.translation_key == "set_schedule_failed"
+
+
+@pytest.mark.usefixtures("setup_integration")
+async def test_set_hot_water_schedule_rejects_main_device(
+ hass: HomeAssistant,
+ device_entry: dr.DeviceEntry,
+) -> None:
+ """Test that picking the main device for hot water schedule is rejected."""
+ with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
@@ -259,7 +294,7 @@ async def test_api_error(
blocking=True,
)
- assert exc_info.value.translation_key == "set_schedule_failed"
+ assert exc_info.value.translation_key == "not_a_water_heater_device"
@pytest.mark.usefixtures("setup_integration")
@@ -276,7 +311,7 @@ async def test_api_error(
)
async def test_time_validation_errors(
hass: HomeAssistant,
- device_entry: dr.DeviceEntry,
+ water_heater_device_entry: dr.DeviceEntry,
start_time: time | str,
end_time: time | str,
expected_error: str,
@@ -287,7 +322,7 @@ async def test_time_validation_errors(
DOMAIN,
"set_hot_water_schedule",
{
- "device_id": device_entry.id,
+ "device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": start_time, "end_time": end_time},
],
@@ -302,7 +337,7 @@ async def test_time_validation_errors(
async def test_unprovided_days_are_none(
hass: HomeAssistant,
mock_bsblan: MagicMock,
- device_entry: dr.DeviceEntry,
+ water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test that unprovided days are sent as None to BSB-LAN API."""
# Only provide Monday and Tuesday, leave other days unprovided
@@ -310,7 +345,7 @@ async def test_unprovided_days_are_none(
DOMAIN,
"set_hot_water_schedule",
{
- "device_id": device_entry.id,
+ "device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
@@ -346,7 +381,7 @@ async def test_unprovided_days_are_none(
async def test_string_time_formats(
hass: HomeAssistant,
mock_bsblan: MagicMock,
- device_entry: dr.DeviceEntry,
+ water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test service with string time formats."""
# Test with string time formats
@@ -354,7 +389,7 @@ async def test_string_time_formats(
DOMAIN,
"set_hot_water_schedule",
{
- "device_id": device_entry.id,
+ "device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": "06:00:00", "end_time": "08:00:00"}, # With seconds
],
@@ -382,7 +417,7 @@ async def test_string_time_formats(
@pytest.mark.usefixtures("setup_integration")
async def test_non_standard_time_types(
hass: HomeAssistant,
- device_entry: dr.DeviceEntry,
+ water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test service with non-standard time types raises error."""
# Test with integer time values - schema validation will reject these
@@ -391,7 +426,7 @@ async def test_non_standard_time_types(
DOMAIN,
"set_hot_water_schedule",
{
- "device_id": device_entry.id,
+ "device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": 600, "end_time": 800},
],
diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py
index b730ddad688f..d7ef7f0718f3 100644
--- a/tests/components/bsblan/test_water_heater.py
+++ b/tests/components/bsblan/test_water_heater.py
@@ -1,5 +1,6 @@
"""Tests for the BSB-LAN water heater platform."""
+import asyncio
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock
@@ -392,6 +393,41 @@ async def test_water_heater_custom_temperature_limits_from_config(
) # Custom maximum from nominal_setpoint_max
+async def test_water_heater_temperature_limits_update_after_slow_fetch(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test temperature limits update when the background fetch completes."""
+ release = asyncio.Event()
+ config = mock_bsblan.hot_water_config.return_value
+ config.reduced_setpoint.value = 15.0
+ config.nominal_setpoint_max.value = 75.0
+
+ async def _blocking_config(*args: object, **kwargs: object) -> object:
+ await release.wait()
+ return config
+
+ mock_bsblan.hot_water_config.side_effect = _blocking_config
+
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ state = hass.states.get(ENTITY_ID)
+ assert state is not None
+ assert state.attributes["min_temp"] == 10.0
+ assert state.attributes["max_temp"] == 65.0
+
+ release.set()
+ await hass.async_block_till_done(wait_background_tasks=True)
+
+ state = hass.states.get(ENTITY_ID)
+ assert state is not None
+ assert state.attributes["min_temp"] == 15.0
+ assert state.attributes["max_temp"] == 75.0
+
+
async def test_turn_on(
hass: HomeAssistant,
mock_bsblan: AsyncMock,
diff --git a/tests/components/calendar/test_llm.py b/tests/components/calendar/test_llm.py
new file mode 100644
index 000000000000..056ff357d69e
--- /dev/null
+++ b/tests/components/calendar/test_llm.py
@@ -0,0 +1,172 @@
+"""Tests for the calendar LLM tools platform."""
+
+from datetime import timedelta
+
+from freezegun import freeze_time
+import pytest
+
+from homeassistant.components import calendar, llm as llm_component
+from homeassistant.components.calendar import llm as calendar_llm
+from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
+from homeassistant.core import Context, HomeAssistant, SupportsResponse
+from homeassistant.helpers import entity_registry as er, llm
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
+
+from tests.common import async_mock_service
+
+ENTITY_ID = "calendar.test_calendar"
+
+
+@pytest.fixture(autouse=True)
+async def setup_integrations(hass: HomeAssistant) -> None:
+ """Set up the integrations and expose a calendar."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ assert await async_setup_component(hass, "calendar", {})
+ assert await async_setup_component(hass, "llm", {})
+ hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Mock Calendar Name"})
+ async_expose_entity(hass, "conversation", ENTITY_ID, True)
+ await hass.async_block_till_done()
+
+
+def _llm_context() -> llm.LLMContext:
+ """Return an LLM context for the conversation assistant."""
+ return llm.LLMContext(
+ platform="test_platform",
+ context=Context(),
+ language="*",
+ assistant="conversation",
+ device_id=None,
+ )
+
+
+async def test_get_tools_no_exposed_calendar(hass: HomeAssistant) -> None:
+ """Test no calendar tool is offered when no calendar is exposed."""
+ async_expose_entity(hass, "conversation", ENTITY_ID, False)
+ result = await llm_component.async_get_tools(hass, _llm_context(), "assist")
+ assert "calendar_get_events" not in [tool.name for tool in result.tools]
+ assert calendar_llm.async_get_tools(hass, _llm_context(), "assist") is None
+
+
+async def test_no_tools_for_other_api(hass: HomeAssistant) -> None:
+ """Test the platform returns None for an unsupported API."""
+ assert calendar_llm.async_get_tools(hass, _llm_context(), "other") is None
+
+
+async def test_calendar_get_events_tool(hass: HomeAssistant) -> None:
+ """Test the calendar get events tool is exposed and works via the platform."""
+ llm_context = _llm_context()
+ result = await llm_component.async_get_tools(hass, llm_context, "assist")
+ tool = next(
+ (tool for tool in result.tools if tool.name == "calendar_get_events"), None
+ )
+ assert tool is not None
+ assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"]
+
+ calls = async_mock_service(
+ hass,
+ domain=calendar.DOMAIN,
+ service=calendar.SERVICE_GET_EVENTS,
+ schema=calendar.SERVICE_GET_EVENTS_SCHEMA,
+ response={
+ ENTITY_ID: {
+ "events": [
+ {
+ "start": "2025-09-17",
+ "end": "2025-09-18",
+ "summary": "Home Assistant 12th birthday",
+ "description": "",
+ },
+ {
+ "start": "2025-09-17T14:00:00-05:00",
+ "end": "2025-09-18T15:00:00-05:00",
+ "summary": "Champagne",
+ "description": "",
+ },
+ ]
+ }
+ },
+ supports_response=SupportsResponse.ONLY,
+ )
+
+ tool_input = llm.ToolInput(
+ tool_name="calendar_get_events",
+ tool_args={"calendar": "Mock Calendar Name", "range": "today"},
+ )
+ now = dt_util.now()
+ with freeze_time(now):
+ response = await tool.async_call(hass, tool_input, llm_context)
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == calendar.DOMAIN
+ assert call.service == calendar.SERVICE_GET_EVENTS
+ assert call.data == {
+ "entity_id": [ENTITY_ID],
+ "start_date_time": now,
+ "end_date_time": dt_util.start_of_local_day(now) + timedelta(days=1),
+ }
+
+ assert response == {
+ "success": True,
+ "result": [
+ {
+ "start": "2025-09-17",
+ "end": "2025-09-18",
+ "summary": "Home Assistant 12th birthday",
+ "description": "",
+ "all_day": True,
+ },
+ {
+ "start": "2025-09-17T14:00:00-05:00",
+ "end": "2025-09-18T15:00:00-05:00",
+ "summary": "Champagne",
+ "description": "",
+ },
+ ],
+ }
+
+ # The "week" range searches seven days out.
+ calls.clear()
+ tool_input.tool_args["range"] = "week"
+ with freeze_time(now):
+ await tool.async_call(hass, tool_input, llm_context)
+ assert call.domain == calendar.DOMAIN
+ assert calls[0].data["end_date_time"] == (
+ dt_util.start_of_local_day(now) + timedelta(days=7)
+ )
+
+
+async def test_calendar_get_events_tool_not_found(hass: HomeAssistant) -> None:
+ """Test the tool reports when the requested calendar no longer matches."""
+ llm_context = _llm_context()
+ result = await llm_component.async_get_tools(hass, llm_context, "assist")
+ tool = next(tool for tool in result.tools if tool.name == "calendar_get_events")
+
+ # Unexpose after the tool (and its calendar enum) was built, so the call-time
+ # match no longer finds the calendar.
+ async_expose_entity(hass, "conversation", ENTITY_ID, False)
+ response = await tool.async_call(
+ hass,
+ llm.ToolInput(
+ "calendar_get_events", {"calendar": "Mock Calendar Name", "range": "today"}
+ ),
+ llm_context,
+ )
+ assert response == {"success": False, "error": "Calendar not found"}
+
+
+async def test_calendar_get_events_tool_uses_aliases(
+ hass: HomeAssistant, entity_registry: er.EntityRegistry
+) -> None:
+ """Test exposed calendar aliases are offered as valid tool values."""
+ entry = entity_registry.async_get_or_create(
+ "calendar", "test", "aliased", suggested_object_id="aliased"
+ )
+ entity_registry.async_update_entity(entry.entity_id, aliases={"Family Calendar"})
+ hass.states.async_set(entry.entity_id, "on")
+ async_expose_entity(hass, "conversation", entry.entity_id, True)
+
+ result = await llm_component.async_get_tools(hass, _llm_context(), "assist")
+ tool = next(tool for tool in result.tools if tool.name == "calendar_get_events")
+ assert "Family Calendar" in tool.parameters.schema["calendar"].container
diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py
index dcd7b1faa835..2864c85a3ddb 100644
--- a/tests/components/calendar/test_trigger.py
+++ b/tests/components/calendar/test_trigger.py
@@ -331,10 +331,14 @@ def target_calendars(
label_on_devices = label_registry.async_create("label_on_devices")
device_calendar_1 = dr.DeviceEntry(
- id="device_calendar_1", labels=[label_on_devices.label_id]
+ config_entry_id="mock-config-entry",
+ id="device_calendar_1",
+ labels=[label_on_devices.label_id],
)
device_calendar_2 = dr.DeviceEntry(
- id="device_calendar_2", labels=[label_on_devices.label_id]
+ config_entry_id="mock-config-entry",
+ id="device_calendar_2",
+ labels=[label_on_devices.label_id],
)
mock_device_registry(
hass,
diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr
index 226426353755..83dec59d147b 100644
--- a/tests/components/cambridge_audio/snapshots/test_init.ambr
+++ b/tests/components/cambridge_audio/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'http://192.168.20.218',
'connections': set({
}),
@@ -24,7 +24,6 @@
'model_id': None,
'name': 'Cambridge Audio CXNv2',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': '0020c2d8',
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/casper_glow/snapshots/test_init.ambr b/tests/components/casper_glow/snapshots/test_init.ambr
index 235ab505621d..5ffd48e71d33 100644
--- a/tests/components/casper_glow/snapshots/test_init.ambr
+++ b/tests/components/casper_glow/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
tuple(
@@ -24,7 +24,6 @@
'model_id': 'G01',
'name': 'Jar',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/chess_com/snapshots/test_init.ambr b/tests/components/chess_com/snapshots/test_init.ambr
index 32d14022d105..2097ba150e13 100644
--- a/tests/components/chess_com/snapshots/test_init.ambr
+++ b/tests/components/chess_com/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': None,
'connections': set({
}),
@@ -24,7 +24,6 @@
'model_id': None,
'name': 'Joost',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/cielo_home/snapshots/test_sensor.ambr b/tests/components/cielo_home/snapshots/test_sensor.ambr
new file mode 100644
index 000000000000..fc17c32ff764
--- /dev/null
+++ b/tests/components/cielo_home/snapshots/test_sensor.ambr
@@ -0,0 +1,114 @@
+# serializer version: 1
+# name: test_all_entities[sensor.living_room_living_room_humidity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': dict({
+ : ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.living_room_living_room_humidity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Humidity',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Humidity',
+ 'platform': 'cielo_home',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'device_1-humidity',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_all_entities[sensor.living_room_living_room_humidity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'humidity',
+ : 'Living Room Humidity',
+ : ,
+ : '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.living_room_living_room_humidity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '40',
+ })
+# ---
+# name: test_all_entities[sensor.living_room_living_room_temperature-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': dict({
+ : ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.living_room_living_room_temperature',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Temperature',
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 1,
+ }),
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Temperature',
+ 'platform': 'cielo_home',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'device_1-temperature',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_all_entities[sensor.living_room_living_room_temperature-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'temperature',
+ : 'Living Room Temperature',
+ : ,
+ : ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.living_room_living_room_temperature',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '22',
+ })
+# ---
diff --git a/tests/components/cielo_home/test_sensor.py b/tests/components/cielo_home/test_sensor.py
new file mode 100644
index 000000000000..aff732ae9154
--- /dev/null
+++ b/tests/components/cielo_home/test_sensor.py
@@ -0,0 +1,89 @@
+"""Tests for the Cielo Home sensor platform."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.const import Platform, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+from homeassistant.util.unit_system import (
+ METRIC_SYSTEM,
+ US_CUSTOMARY_SYSTEM,
+ UnitSystem,
+)
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+@pytest.fixture(autouse=True)
+def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
+ """Make sure all entities are enabled."""
+
+
+@pytest.mark.usefixtures("mock_cielo_client", "mock_cielo_device_api")
+async def test_all_entities(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ mock_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test all sensor entities."""
+ with patch("homeassistant.components.cielo_home.PLATFORMS", [Platform.SENSOR]):
+ mock_config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+@pytest.mark.usefixtures("mock_cielo_client")
+@pytest.mark.parametrize(
+ ("temperature_unit", "hass_units", "expected_unit"),
+ [
+ pytest.param(
+ "°F",
+ US_CUSTOMARY_SYSTEM,
+ UnitOfTemperature.FAHRENHEIT,
+ id="fahrenheit",
+ ),
+ pytest.param(
+ "unknown",
+ METRIC_SYSTEM,
+ UnitOfTemperature.CELSIUS,
+ id="unknown_unit",
+ ),
+ pytest.param(
+ None,
+ METRIC_SYSTEM,
+ UnitOfTemperature.CELSIUS,
+ id="none_unit",
+ ),
+ ],
+)
+async def test_temperature_sensor_unit(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_cielo_device_api: MagicMock,
+ entity_registry: er.EntityRegistry,
+ temperature_unit: str | None,
+ hass_units: UnitSystem,
+ expected_unit: str,
+) -> None:
+ """Test temperature sensor reports the correct unit."""
+ mock_cielo_device_api.temperature_unit.return_value = temperature_unit
+ hass.config.units = hass_units
+
+ mock_config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.living_room_living_room_temperature")
+ assert state is not None
+ assert state.attributes.get("unit_of_measurement") == expected_unit
+
+ entry = entity_registry.async_get("sensor.living_room_living_room_temperature")
+ assert entry is not None
+ assert entry.unit_of_measurement == expected_unit
diff --git a/tests/components/citybikes/snapshots/test_sensor.ambr b/tests/components/citybikes/snapshots/test_sensor.ambr
index 3da6b71e0b81..91bb5626036b 100644
--- a/tests/components/citybikes/snapshots/test_sensor.ambr
+++ b/tests/components/citybikes/snapshots/test_sensor.ambr
@@ -7,8 +7,8 @@
'free_ebikes': 2,
: 'Station 1',
: 'mdi:bike',
- 'latitude': 40.0,
- 'longitude': -73.0,
+ : 40.0,
+ : -73.0,
'timestamp': '2026-03-22T00:00:00Z',
'uid': 'uid-1',
: 'bikes',
@@ -29,8 +29,8 @@
'free_ebikes': None,
: 'Station 1',
: 'mdi:bike',
- 'latitude': 40.0,
- 'longitude': -73.0,
+ : 40.0,
+ : -73.0,
'timestamp': '2026-03-22T00:00:00Z',
'uid': 'uid-1',
: 'bikes',
diff --git a/tests/components/climate/test_llm.py b/tests/components/climate/test_llm.py
new file mode 100644
index 000000000000..85103a855768
--- /dev/null
+++ b/tests/components/climate/test_llm.py
@@ -0,0 +1,58 @@
+"""Tests for the climate LLM tools platform."""
+
+import pytest
+
+from homeassistant.components import llm as llm_component
+from homeassistant.components.climate import llm as climate_llm
+from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
+from homeassistant.core import Context, HomeAssistant
+from homeassistant.helpers import llm
+from homeassistant.setup import async_setup_component
+
+ENTITY_ID = "climate.test"
+
+
+@pytest.fixture(autouse=True)
+async def setup_integrations(hass: HomeAssistant) -> None:
+ """Set up the integrations and expose a climate entity."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ assert await async_setup_component(hass, "intent", {})
+ assert await async_setup_component(hass, "climate", {})
+ assert await async_setup_component(hass, "llm", {})
+ hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test climate"})
+ async_expose_entity(hass, "conversation", ENTITY_ID, True)
+ await hass.async_block_till_done()
+
+
+def _llm_context() -> llm.LLMContext:
+ """Return an LLM context for the conversation assistant."""
+ return llm.LLMContext(
+ platform="test_platform",
+ context=Context(),
+ language="*",
+ assistant="conversation",
+ device_id=None,
+ )
+
+
+async def _tool_names(hass: HomeAssistant) -> set[str]:
+ """Return the names of the tools offered by the climate platform."""
+ result = await llm_component.async_get_tools(hass, _llm_context(), "assist")
+ return {tool.name for tool in result.tools}
+
+
+async def test_intent_tool_exposed(hass: HomeAssistant) -> None:
+ """Test the intent tool is offered for an exposed climate entity."""
+ assert "HassClimateSetTemperature" in await _tool_names(hass)
+
+
+async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None:
+ """Test the intent tool is hidden when no climate entity is exposed."""
+ async_expose_entity(hass, "conversation", ENTITY_ID, False)
+ assert "HassClimateSetTemperature" not in await _tool_names(hass)
+ assert climate_llm.async_get_tools(hass, _llm_context(), "assist") is None
+
+
+async def test_no_tools_for_other_api(hass: HomeAssistant) -> None:
+ """Test the platform returns None for an unsupported API."""
+ assert climate_llm.async_get_tools(hass, _llm_context(), "other") is None
diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr
index e1cf19020867..72b13befc4ce 100644
--- a/tests/components/cloud/snapshots/test_http_api.ambr
+++ b/tests/components/cloud/snapshots/test_http_api.ambr
@@ -21,7 +21,7 @@
## Active Integrations
- Built-in integrations: 22
+ Built-in integrations: 23
Custom integrations: 1
Built-in integrations
@@ -42,6 +42,7 @@
homeassistant | Home Assistant Core Integration
http | HTTP
intent | Intent
+ llm | LLM
media_source | Media Source
mock_no_info_integration | mock_no_info_integration
repairs | Repairs
@@ -156,7 +157,7 @@
## Active Integrations
- Built-in integrations: 22
+ Built-in integrations: 23
Custom integrations: 0
Built-in integrations
@@ -177,6 +178,7 @@
homeassistant | Home Assistant Core Integration
http | HTTP
intent | Intent
+ llm | LLM
media_source | Media Source
mock_no_info_integration | mock_no_info_integration
repairs | Repairs
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
index ce450b89cc6a..6d3d588027ae 100644
--- a/tests/components/cloud/test_http_api.py
+++ b/tests/components/cloud/test_http_api.py
@@ -959,6 +959,8 @@ async def test_websocket_status(
"alexa_default_expose": DEFAULT_EXPOSED_DOMAINS,
"alexa_report_state": True,
"google_report_state": True,
+ "onboarded_items": [],
+ "onboarding_postponed_until": None,
"remote_allow_remote_enable": True,
"remote_enabled": False,
"cloud_ice_servers_enabled": True,
@@ -989,6 +991,8 @@ async def test_websocket_status(
"remote_certificate": None,
"http_use_ssl": False,
"active_subscription": True,
+ "onboarding_completed": False,
+ "onboarding_postponed": False,
}
@@ -1235,6 +1239,106 @@ async def test_websocket_update_preferences_no_token(
assert response["error"]["code"] == "alexa_relink"
+async def test_websocket_cloud_onboarding_postpone(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ cloud: MagicMock,
+ setup_cloud: None,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test postponing onboarding."""
+ client = await hass_ws_client(hass)
+
+ assert cloud.client.prefs.onboarding_postponed is False
+
+ await client.send_json_auto_id({"type": "cloud/onboarding/postpone"})
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"]["onboarding_postponed"] is True
+ assert cloud.client.prefs.onboarding_postponed_until is not None
+
+ freezer.tick(datetime.timedelta(hours=25))
+
+ await client.send_json_auto_id({"type": "cloud/status"})
+ response = await client.receive_json()
+
+ assert response["result"]["onboarding_postponed"] is False
+
+
+async def test_websocket_cloud_onboarding_complete(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ cloud: MagicMock,
+ setup_cloud: None,
+) -> None:
+ """Test completing onboarding items."""
+ client = await hass_ws_client(hass)
+
+ assert cloud.client.prefs.onboarded_items == []
+ assert cloud.client.prefs.onboarding_completed is False
+
+ # Complete a subset of items
+ await client.send_json_auto_id(
+ {"type": "cloud/onboarding/complete", "items": ["remote", "backup"]}
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert cloud.client.prefs.onboarded_items == ["remote", "backup"]
+ assert response["result"]["onboarding_completed"] is False
+
+ # Already-completed items are ignored, only new ones are added
+ await client.send_json_auto_id(
+ {
+ "type": "cloud/onboarding/complete",
+ "items": ["remote", "voice", "streaming"],
+ }
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert cloud.client.prefs.onboarding_completed is True
+ assert response["result"]["onboarding_completed"] is True
+ assert cloud.client.prefs.onboarded_items == [
+ "remote",
+ "backup",
+ "voice",
+ "streaming",
+ ]
+
+ # Completing already-completed items is a no-op
+ await client.send_json_auto_id(
+ {"type": "cloud/onboarding/complete", "items": ["remote", "backup"]}
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert cloud.client.prefs.onboarded_items == [
+ "remote",
+ "backup",
+ "voice",
+ "streaming",
+ ]
+
+
+async def test_websocket_cloud_onboarding_complete_invalid_item(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ cloud: MagicMock,
+ setup_cloud: None,
+) -> None:
+ """Test completing an invalid onboarding item."""
+ client = await hass_ws_client(hass)
+
+ await client.send_json_auto_id(
+ {"type": "cloud/onboarding/complete", "items": ["remote", "invalid"]}
+ )
+ response = await client.receive_json()
+
+ assert not response["success"]
+
+
async def test_enabling_webhook(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
@@ -1789,6 +1893,8 @@ async def test_support_package_requires_admin(
{"type": "cloud/update_prefs", "alexa_report_state": True},
{"type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"},
{"type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"},
+ {"type": "cloud/onboarding/postpone"},
+ {"type": "cloud/onboarding/complete", "items": ["remote"]},
],
)
async def test_ws_commands_require_admin(
diff --git a/tests/components/common.py b/tests/components/common.py
index c8e0a869aa4d..98087ae1e18d 100644
--- a/tests/components/common.py
+++ b/tests/components/common.py
@@ -90,7 +90,12 @@ async def target_entities(
"Test Label"
)
- device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
+ device = dr.DeviceEntry(
+ config_entry_id=config_entry.entry_id,
+ id="test_device",
+ area_id=area.id,
+ labels={label.label_id},
+ )
mock_device_registry(hass, {device.id: device})
entity_reg = er.async_get(hass)
diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py
index 12dec880c4f5..5b8ea0bbd05b 100644
--- a/tests/components/config/test_core.py
+++ b/tests/components/config/test_core.py
@@ -134,7 +134,7 @@ async def test_websocket_core_update(hass: HomeAssistant, client) -> None:
"type": "config/core/update",
"latitude": 60,
"longitude": 50,
- "elevation": 25,
+ "elevation": 25.6,
"location_name": "Huis",
"unit_system": "imperial",
"time_zone": "America/New_York",
diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py
index 4c0f5f18e3bc..153d4f5c685f 100644
--- a/tests/components/config/test_device_registry.py
+++ b/tests/components/config/test_device_registry.py
@@ -61,6 +61,8 @@ async def test_list_devices(
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
+ "config_entry_id": entry.entry_id,
+ "config_subentry_id": None,
"configuration_url": None,
"connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]],
"created_at": utcnow().timestamp(),
@@ -84,6 +86,8 @@ async def test_list_devices(
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
+ "config_entry_id": entry.entry_id,
+ "config_subentry_id": None,
"configuration_url": None,
"connections": [],
"created_at": utcnow().timestamp(),
@@ -119,6 +123,8 @@ async def test_list_devices(
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
+ "config_entry_id": entry.entry_id,
+ "config_subentry_id": None,
"configuration_url": None,
"connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]],
"created_at": utcnow().timestamp(),
@@ -307,7 +313,7 @@ async def test_remove_config_entry_from_device(
entry_2.supports_remove_device = True
entry_2.add_to_hass(hass)
- device_registry.async_get_or_create(
+ device_entry_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
@@ -315,11 +321,14 @@ async def test_remove_config_entry_from_device(
config_entry_id=entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
- assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id}
+ # Identifiers and connections are unique per config entry, so the two config
+ # entries get separate devices even though they share a connection
+ assert device_entry_1.id != device_entry.id
+ assert device_entry.config_entries == {entry_2.entry_id}
- # Try removing a config entry from the device, it should fail because
+ # Try removing the config entry from the device, it should fail because
# async_remove_config_entry_device returns False
- response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
+ response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
@@ -327,26 +336,21 @@ async def test_remove_config_entry_from_device(
# Make async_remove_config_entry_device return True
can_remove = True
- # Remove the 1st config entry
- response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
-
- assert response["success"]
- assert response["result"]["config_entries"] == [entry_2.entry_id]
-
- # Check that the config entry was removed from the device
- assert device_registry.async_get(device_entry.id).config_entries == {
- entry_2.entry_id
- }
-
- # Remove the 2nd config entry
+ # Remove the config entry, this was the device's only config entry so the
+ # device is removed
response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert response["success"]
assert response["result"] is None
- # This was the last config entry, the device is removed
+ # This was the only config entry, the device is removed
assert not device_registry.async_get(device_entry.id)
+ # The device belonging to the other config entry is untouched
+ assert device_registry.async_get(device_entry_1.id).config_entries == {
+ entry_1.entry_id
+ }
+
async def test_remove_config_entry_from_device_fails(
hass: HomeAssistant,
@@ -396,38 +400,38 @@ async def test_remove_config_entry_from_device_fails(
entry_3.supports_remove_device = True
entry_3.add_to_hass(hass)
- device_registry.async_get_or_create(
+ device_entry_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
- device_registry.async_get_or_create(
+ device_entry_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
- device_entry = device_registry.async_get_or_create(
+ device_entry_3 = device_registry.async_get_or_create(
config_entry_id=entry_3.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
- assert device_entry.config_entries == {
- entry_1.entry_id,
- entry_2.entry_id,
- entry_3.entry_id,
- }
+ # Identifiers and connections are unique per config entry, so each config entry
+ # gets its own device even though they share a connection
+ assert device_entry_1.config_entries == {entry_1.entry_id}
+ assert device_entry_2.config_entries == {entry_2.entry_id}
+ assert device_entry_3.config_entries == {entry_3.entry_id}
fake_entry_id = "abc123"
assert entry_1.entry_id != fake_entry_id
fake_device_id = "abc123"
- assert device_entry.id != fake_device_id
+ assert device_entry_3.id != fake_device_id
# Try removing a non existing config entry from the device
- response = await ws_client.remove_device(device_entry.id, fake_entry_id)
+ response = await ws_client.remove_device(device_entry_3.id, fake_entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
assert response["error"]["message"] == "Unknown config entry"
# Try removing a config entry which does not support removal from the device
- response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
+ response = await ws_client.remove_device(device_entry_1.id, entry_1.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
@@ -443,22 +447,22 @@ async def test_remove_config_entry_from_device_fails(
assert response["error"]["message"] == "Unknown device"
# Try removing a config entry from a device which it's not connected to
- response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
-
- assert response["success"]
- assert set(response["result"]["config_entries"]) == {
- entry_1.entry_id,
- entry_3.entry_id,
- }
-
- response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
+ response = await ws_client.remove_device(device_entry_3.id, entry_2.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
assert response["error"]["message"] == "Config entry not in device"
+ # Removing a config entry which supports removal removes the device, since it is
+ # the device's only config entry
+ response = await ws_client.remove_device(device_entry_2.id, entry_2.entry_id)
+
+ assert response["success"]
+ assert response["result"] is None
+ assert not device_registry.async_get(device_entry_2.id)
+
# Try removing a config entry which can't be loaded from a device - allowed
- response = await ws_client.remove_device(device_entry.id, entry_3.entry_id)
+ response = await ws_client.remove_device(device_entry_3.id, entry_3.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
@@ -517,7 +521,7 @@ async def test_remove_config_entry_from_device_if_integration_remove(
entry_2.supports_remove_device = True
entry_2.add_to_hass(hass)
- device_registry.async_get_or_create(
+ device_entry_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
@@ -525,11 +529,14 @@ async def test_remove_config_entry_from_device_if_integration_remove(
config_entry_id=entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
- assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id}
+ # Identifiers and connections are unique per config entry, so the two config
+ # entries get separate devices even though they share a connection
+ assert device_entry_1.id != device_entry.id
+ assert device_entry.config_entries == {entry_2.entry_id}
- # Try removing a config entry from the device, it should fail because
+ # Try removing the config entry from the device, it should fail because
# async_remove_config_entry_device returns False
- response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
+ response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
@@ -537,22 +544,17 @@ async def test_remove_config_entry_from_device_if_integration_remove(
# Make async_remove_config_entry_device return True
can_remove = True
- # Remove the 1st config entry
- response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
-
- assert response["success"]
- assert response["result"]["config_entries"] == [entry_2.entry_id]
-
- # Check that the config entry was removed from the device
- assert device_registry.async_get(device_entry.id).config_entries == {
- entry_2.entry_id
- }
-
- # Remove the 2nd config entry
+ # Remove the config entry, this was the device's only config entry so the
+ # device is removed
response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert response["success"]
assert response["result"] is None
- # This was the last config entry, the device is removed
+ # This was the only config entry, the device is removed
assert not device_registry.async_get(device_entry.id)
+
+ # The device belonging to the other config entry is untouched
+ assert device_registry.async_get(device_entry_1.id).config_entries == {
+ entry_1.entry_id
+ }
diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py
index 5fabcbd9954b..8796370cf782 100644
--- a/tests/components/configurator/test_init.py
+++ b/tests/components/configurator/test_init.py
@@ -5,7 +5,7 @@ from datetime import timedelta
import pytest
from homeassistant.components import configurator
-from homeassistant.const import ATTR_FRIENDLY_NAME
+from homeassistant.const import EntityStateAttribute
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import Unauthorized
from homeassistant.util import dt as dt_util
@@ -40,7 +40,7 @@ async def test_request_least_info(hass: HomeAssistant) -> None:
async def test_request_all_info(hass: HomeAssistant) -> None:
"""Test request config with all possible info."""
exp_attr = {
- ATTR_FRIENDLY_NAME: "Test Request",
+ EntityStateAttribute.FRIENDLY_NAME: "Test Request",
configurator.ATTR_DESCRIPTION: """config description
[link name](link url)
@@ -48,7 +48,7 @@ async def test_request_all_info(hass: HomeAssistant) -> None:
""",
configurator.ATTR_SUBMIT_CAPTION: "config submit caption",
configurator.ATTR_FIELDS: [],
- configurator.ATTR_ENTITY_PICTURE: "config entity picture",
+ EntityStateAttribute.ENTITY_PICTURE: "config entity picture",
configurator.ATTR_CONFIGURE_ID: configurator.async_request_config(
hass,
name="Test Request",
diff --git a/tests/components/conftest.py b/tests/components/conftest.py
index 1ccb6de16684..8fe4d8d26b76 100644
--- a/tests/components/conftest.py
+++ b/tests/components/conftest.py
@@ -895,14 +895,6 @@ def supervisor_client() -> Generator[AsyncMock]:
"homeassistant.components.hassio.coordinator.get_supervisor_client",
return_value=supervisor_client,
),
- patch(
- "homeassistant.components.hassio.issues.get_supervisor_client",
- return_value=supervisor_client,
- ),
- patch(
- "homeassistant.components.hassio.jobs.get_supervisor_client",
- return_value=supervisor_client,
- ),
patch(
"homeassistant.components.hassio.repairs.get_supervisor_client",
return_value=supervisor_client,
diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py
index 38300b88f0aa..de66348dfcc2 100644
--- a/tests/components/control4/conftest.py
+++ b/tests/components/control4/conftest.py
@@ -1,6 +1,7 @@
"""Common fixtures for the Control4 tests."""
from collections.abc import AsyncGenerator, Generator
+import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -54,16 +55,18 @@ def mock_c4_account() -> Generator[MagicMock]:
),
):
mock_account = mock_account_class.return_value
- mock_account.getAccountBearerToken = AsyncMock()
- mock_account.getAccountControllers = AsyncMock(
+ mock_account.get_account_bearer_token = AsyncMock()
+ mock_account.get_account_controllers = AsyncMock(
return_value={
"controllerCommonName": "control4_model_00AA00AA00AA",
"href": "https://apis.control4.com/account/v3/rest/accounts/000000",
"name": "Name",
}
)
- mock_account.getDirectorBearerToken = AsyncMock(return_value={"token": "test"})
- mock_account.getControllerOSVersion = AsyncMock(return_value="3.2.0")
+ mock_account.get_director_bearer_token = AsyncMock(
+ return_value={"token": "test", "validSeconds": 86400}
+ )
+ mock_account.get_controller_os_version = AsyncMock(return_value="3.2.0")
yield mock_account
@@ -80,14 +83,12 @@ def mock_c4_director() -> Generator[MagicMock]:
),
):
mock_director = mock_director_class.return_value
- # Multi-platform setup: media room, climate room, shared devices
- # Note: The API returns JSON strings, so we load fixtures as strings
- mock_director.getAllItemInfo = AsyncMock(
- return_value=load_fixture("director_all_items.json", DOMAIN)
- )
- mock_director.getUiConfiguration = AsyncMock(
- return_value=load_fixture("ui_configuration.json", DOMAIN)
+ all_items = json.loads(load_fixture("director_all_items.json", DOMAIN))
+ mock_director.get_all_item_info = AsyncMock(return_value=all_items)
+ mock_director.get_ui_configuration = AsyncMock(
+ return_value=json.loads(load_fixture("ui_configuration.json", DOMAIN))
)
+ mock_director.get_item_variables = AsyncMock(return_value=[])
yield mock_director
@@ -157,12 +158,12 @@ def mock_c4_climate() -> Generator[MagicMock]:
"homeassistant.components.control4.climate.C4Climate", autospec=True
) as mock_class:
mock_instance = mock_class.return_value
- mock_instance.setHvacMode = AsyncMock()
- mock_instance.setHeatSetpointF = AsyncMock()
- mock_instance.setCoolSetpointF = AsyncMock()
- mock_instance.setFanMode = AsyncMock()
- mock_instance.setHeatSetpointC = AsyncMock()
- mock_instance.setCoolSetpointC = AsyncMock()
+ mock_instance.set_hvac_mode = AsyncMock()
+ mock_instance.set_heat_setpoint_f = AsyncMock()
+ mock_instance.set_cool_setpoint_f = AsyncMock()
+ mock_instance.set_fan_mode = AsyncMock()
+ mock_instance.set_heat_setpoint_c = AsyncMock()
+ mock_instance.set_cool_setpoint_c = AsyncMock()
yield mock_instance
diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py
index d7e348db9bb5..6f6f628cbfd5 100644
--- a/tests/components/control4/test_climate.py
+++ b/tests/components/control4/test_climate.py
@@ -253,7 +253,7 @@ async def test_set_hvac_mode(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode},
blocking=True,
)
- mock_c4_climate.setHvacMode.assert_called_once_with(expected_c4_mode)
+ mock_c4_climate.set_hvac_mode.assert_called_once_with(expected_c4_mode)
@pytest.mark.parametrize(
@@ -265,7 +265,7 @@ async def test_set_hvac_mode(
temperature=72.5,
humidity=45,
),
- "setHeatSetpointF",
+ "set_heat_setpoint_f",
id="heat",
),
pytest.param(
@@ -275,7 +275,7 @@ async def test_set_hvac_mode(
temperature=74.0,
cool_setpoint=72.0,
),
- "setCoolSetpointF",
+ "set_cool_setpoint_f",
id="cool",
),
],
@@ -330,8 +330,8 @@ async def test_set_temperature_range_auto_mode(
},
blocking=True,
)
- mock_c4_climate.setHeatSetpointF.assert_called_once_with(65.0)
- mock_c4_climate.setCoolSetpointF.assert_called_once_with(78.0)
+ mock_c4_climate.set_heat_setpoint_f.assert_called_once_with(65.0)
+ mock_c4_climate.set_cool_setpoint_f.assert_called_once_with(78.0)
@pytest.mark.parametrize("mock_climate_variables", [{}])
@@ -482,7 +482,7 @@ async def test_set_fan_mode(
blocking=True,
)
# Verify the Control4 API is called with the C4 format ("On" not "on")
- mock_c4_climate.setFanMode.assert_called_once_with("On")
+ mock_c4_climate.set_fan_mode.assert_called_once_with("On")
@pytest.mark.parametrize(
@@ -530,14 +530,14 @@ async def test_fan_mode_not_supported(
[
pytest.param(
_make_climate_data(hvac_state="Off", hvac_mode="Heat"),
- "setHeatSetpointF",
- "setHeatSetpointC",
+ "set_heat_setpoint_f",
+ "set_heat_setpoint_c",
id="fahrenheit_heat_calls_F_not_C",
),
pytest.param(
_make_climate_data(hvac_state="Cool", hvac_mode="Cool"),
- "setCoolSetpointF",
- "setCoolSetpointC",
+ "set_cool_setpoint_f",
+ "set_cool_setpoint_c",
id="fahrenheit_cool_calls_F_not_C",
),
],
diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py
index 773f692c2ef3..0e99ef1e4ade 100644
--- a/tests/components/control4/test_config_flow.py
+++ b/tests/components/control4/test_config_flow.py
@@ -80,7 +80,7 @@ async def test_user_flow_errors(
DOMAIN, context={"source": SOURCE_USER}
)
- mock_c4_account.getAccountBearerToken.side_effect = exception
+ mock_c4_account.get_account_bearer_token.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -94,7 +94,7 @@ async def test_user_flow_errors(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
- mock_c4_account.getAccountBearerToken.side_effect = None
+ mock_c4_account.get_account_bearer_token.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -130,7 +130,7 @@ async def test_user_flow_director_errors(
DOMAIN, context={"source": SOURCE_USER}
)
- mock_c4_director.getAllItemInfo.side_effect = exception
+ mock_c4_director.get_all_item_info.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -144,7 +144,7 @@ async def test_user_flow_director_errors(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
- mock_c4_director.getAllItemInfo.side_effect = None
+ mock_c4_director.get_all_item_info.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/tests/components/control4/test_cover.py b/tests/components/control4/test_cover.py
index 55b42165a3ef..84c81083905f 100644
--- a/tests/components/control4/test_cover.py
+++ b/tests/components/control4/test_cover.py
@@ -93,7 +93,7 @@ def mock_c4_blind() -> Generator[MagicMock]:
mock_instance.open = AsyncMock()
mock_instance.close = AsyncMock()
mock_instance.stop = AsyncMock()
- mock_instance.setLevelTarget = AsyncMock()
+ mock_instance.set_level_target = AsyncMock()
yield mock_instance
@@ -245,14 +245,14 @@ async def test_set_cover_position(
hass: HomeAssistant,
mock_c4_blind: MagicMock,
) -> None:
- """Test setting cover position calls setLevelTarget with the requested value."""
+ """Test setting cover position calls set_level_target with the requested value."""
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 75},
blocking=True,
)
- mock_c4_blind.setLevelTarget.assert_called_once_with(75)
+ mock_c4_blind.set_level_target.assert_called_once_with(75)
@pytest.mark.parametrize("mock_cover_variables", [{}])
diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py
index 164a6aafe510..1af92459d047 100644
--- a/tests/components/conversation/test_chat_log.py
+++ b/tests/components/conversation/test_chat_log.py
@@ -25,14 +25,22 @@ from homeassistant.components.conversation.chat_log import (
ChatLogEventType,
async_subscribe_chat_logs,
)
+from homeassistant.components.llm import LLMTools
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, llm
+from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import async_fire_time_changed
+@pytest.fixture(autouse=True)
+async def setup_llm(hass: HomeAssistant) -> None:
+ """Set up the llm integration so the Assist API can pull its tools."""
+ assert await async_setup_component(hass, "llm", {})
+
+
async def test_cleanup(
hass: HomeAssistant,
mock_conversation_input: ConversationInput,
@@ -434,67 +442,67 @@ async def test_tool_call(
)
mock_tool.async_call.return_value = "Test response"
- with patch(
- "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[]
- ) as mock_get_tools:
- mock_get_tools.return_value = [mock_tool]
+ with (
+ patch(
+ "homeassistant.components.llm.async_get_tools",
+ new_callable=AsyncMock,
+ return_value=LLMTools(tools=[mock_tool]),
+ ),
+ chat_session.async_get_chat_session(hass) as session,
+ async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
+ ):
+ await chat_log.async_provide_llm_data(
+ mock_conversation_input.as_llm_context("test"),
+ user_llm_hass_api="assist",
+ user_llm_prompt=None,
+ )
+ content = AssistantContent(
+ agent_id=mock_conversation_input.agent_id,
+ content="",
+ tool_calls=[
+ llm.ToolInput(
+ id="mock-tool-call-id",
+ tool_name="test_tool",
+ tool_args={"param1": "Test Param"},
+ ),
+ llm.ToolInput(
+ id="mock-tool-call-id-2",
+ tool_name="test_tool",
+ tool_args={"param1": "Test Param"},
+ ),
+ ],
+ )
- with (
- chat_session.async_get_chat_session(hass) as session,
- async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
- ):
- await chat_log.async_provide_llm_data(
- mock_conversation_input.as_llm_context("test"),
- user_llm_hass_api="assist",
- user_llm_prompt=None,
+ tool_call_tasks = {
+ tool_call_id: hass.async_create_task(
+ chat_log.llm_api.async_call_tool(content.tool_calls[0]),
+ tool_call_id,
)
- content = AssistantContent(
- agent_id=mock_conversation_input.agent_id,
- content="",
- tool_calls=[
- llm.ToolInput(
- id="mock-tool-call-id",
- tool_name="test_tool",
- tool_args={"param1": "Test Param"},
- ),
- llm.ToolInput(
- id="mock-tool-call-id-2",
- tool_name="test_tool",
- tool_args={"param1": "Test Param"},
- ),
- ],
+ for tool_call_id in prerun_tool_tasks
+ }
+
+ with pytest.raises(ValueError):
+ chat_log.async_add_assistant_content_without_tools(content)
+
+ results = [
+ tool_result_content
+ async for tool_result_content in chat_log.async_add_assistant_content(
+ content, tool_call_tasks=tool_call_tasks or None
)
+ ]
- tool_call_tasks = {
- tool_call_id: hass.async_create_task(
- chat_log.llm_api.async_call_tool(content.tool_calls[0]),
- tool_call_id,
- )
- for tool_call_id in prerun_tool_tasks
- }
-
- with pytest.raises(ValueError):
- chat_log.async_add_assistant_content_without_tools(content)
-
- results = [
- tool_result_content
- async for tool_result_content in chat_log.async_add_assistant_content(
- content, tool_call_tasks=tool_call_tasks or None
- )
- ]
-
- assert results[0] == ToolResultContent(
- agent_id=mock_conversation_input.agent_id,
- tool_call_id="mock-tool-call-id",
- tool_result="Test response",
- tool_name="test_tool",
- )
- assert results[1] == ToolResultContent(
- agent_id=mock_conversation_input.agent_id,
- tool_call_id="mock-tool-call-id-2",
- tool_result="Test response",
- tool_name="test_tool",
- )
+ assert results[0] == ToolResultContent(
+ agent_id=mock_conversation_input.agent_id,
+ tool_call_id="mock-tool-call-id",
+ tool_result="Test response",
+ tool_name="test_tool",
+ )
+ assert results[1] == ToolResultContent(
+ agent_id=mock_conversation_input.agent_id,
+ tool_call_id="mock-tool-call-id-2",
+ tool_result="Test response",
+ tool_name="test_tool",
+ )
@freeze_time("2025-10-31 12:00:00")
@@ -514,12 +522,13 @@ async def test_tool_call_exception(
with (
patch(
- "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[]
- ) as mock_get_tools,
+ "homeassistant.components.llm.async_get_tools",
+ new_callable=AsyncMock,
+ return_value=LLMTools(tools=[mock_tool]),
+ ),
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
- mock_get_tools.return_value = [mock_tool]
await chat_log.async_provide_llm_data(
mock_conversation_input.as_llm_context("test"),
user_llm_hass_api="assist",
@@ -712,8 +721,10 @@ async def test_add_delta_content_stream(
with (
patch(
- "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[]
- ) as mock_get_tools,
+ "homeassistant.components.llm.async_get_tools",
+ new_callable=AsyncMock,
+ return_value=LLMTools(tools=[mock_tool]),
+ ),
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(
hass,
@@ -724,7 +735,6 @@ async def test_add_delta_content_stream(
),
) as chat_log,
):
- mock_get_tools.return_value = [mock_tool]
await chat_log.async_provide_llm_data(
mock_conversation_input.as_llm_context("test"),
user_llm_hass_api="assist",
diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py
index ab0acd3d1fac..3b852d7883fc 100644
--- a/tests/components/conversation/test_default_agent.py
+++ b/tests/components/conversation/test_default_agent.py
@@ -351,7 +351,7 @@ async def test_expose_flag_automatically_set(
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
- with patch("homeassistant.components.http.start_http_server_and_save_config"):
+ with patch("homeassistant.components.http.HomeAssistantHTTP.start"):
await hass.async_start()
# After setting up conversation, the expose flag should now be set on all entities
diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py
index 61f63f4a6e9f..8ec76c557497 100644
--- a/tests/components/counter/test_init.py
+++ b/tests/components/counter/test_init.py
@@ -6,7 +6,6 @@ from typing import Any
import pytest
from homeassistant.components.counter import (
- ATTR_EDITABLE,
ATTR_INITIAL,
ATTR_MAXIMUM,
ATTR_MINIMUM,
@@ -24,7 +23,13 @@ from homeassistant.components.counter import (
SERVICE_SET_VALUE,
VALUE,
)
-from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME
+from homeassistant.const import (
+ ATTR_EDITABLE,
+ ATTR_ENTITY_ID,
+ ATTR_FRIENDLY_NAME,
+ ATTR_ICON,
+ ATTR_NAME,
+)
from homeassistant.core import Context, CoreState, HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
diff --git a/tests/components/counter/test_reproduce_state.py b/tests/components/counter/test_reproduce_state.py
index 6b985b5a6872..7cd959dcf111 100644
--- a/tests/components/counter/test_reproduce_state.py
+++ b/tests/components/counter/test_reproduce_state.py
@@ -15,44 +15,34 @@ async def test_reproducing_states(
"""Test reproducing Counter states."""
hass.states.async_set("counter.entity", "5", {})
hass.states.async_set(
- "counter.entity_attr",
- "8",
- {"minimum": 5, "maximum": 15, "step": 3},
+ "counter.entity_attr", "8", {"minimum": 5, "maximum": 15, "step": 3}
)
- configure_calls = async_mock_service(hass, DOMAIN, "set_value")
+ set_value_calls = async_mock_service(hass, DOMAIN, "set_value")
- # These calls should do nothing as entities already in desired state
+ # These calls should do nothing as entity already in desired state
await async_reproduce_state(
hass,
[
State("counter.entity", "5"),
- State(
- "counter.entity_attr",
- "8",
- {"minimum": 5, "maximum": 15, "step": 3},
- ),
+ State("counter.entity_attr", "8"),
],
)
- assert len(configure_calls) == 0
+ assert len(set_value_calls) == 0
# Test invalid state is handled
await async_reproduce_state(hass, [State("counter.entity", "not_supported")])
assert "not_supported" in caplog.text
- assert len(configure_calls) == 0
+ assert len(set_value_calls) == 0
# Make sure correct services are called
await async_reproduce_state(
hass,
[
State("counter.entity", "2"),
- State(
- "counter.entity_attr",
- "7",
- {"minimum": 3, "maximum": 21, "step": 5},
- ),
+ State("counter.entity_attr", "7", {"minimum": 3, "maximum": 21, "step": 5}),
# Should not raise
State("counter.non_existing", "6"),
],
@@ -60,16 +50,10 @@ async def test_reproducing_states(
valid_calls = [
{"entity_id": "counter.entity", "value": "2"},
- {
- "entity_id": "counter.entity_attr",
- "value": "7",
- "minimum": 3,
- "maximum": 21,
- "step": 5,
- },
+ {"entity_id": "counter.entity_attr", "value": "7"},
]
- assert len(configure_calls) == 2
- for call in configure_calls:
+ assert len(set_value_calls) == 2
+ for call in set_value_calls:
assert call.domain == "counter"
assert call.data in valid_calls
valid_calls.remove(call.data)
diff --git a/tests/components/data_grand_lyon/conftest.py b/tests/components/data_grand_lyon/conftest.py
index 5cbf124aa2d5..672ec2baba59 100644
--- a/tests/components/data_grand_lyon/conftest.py
+++ b/tests/components/data_grand_lyon/conftest.py
@@ -5,6 +5,7 @@ from datetime import datetime
from unittest.mock import AsyncMock, patch
from data_grand_lyon_ha import (
+ TclParkAndRide,
TclPassage,
TclPassageType,
TclStop,
@@ -17,9 +18,11 @@ import pytest
from homeassistant.components.data_grand_lyon.const import (
CONF_LINE,
+ CONF_PARK_ID,
CONF_STATION_ID,
CONF_STOP_ID,
DOMAIN,
+ SUBENTRY_TYPE_PARK_AND_RIDE,
SUBENTRY_TYPE_STOP,
SUBENTRY_TYPE_VELOV_STATION,
)
@@ -138,6 +141,36 @@ MOCK_VELOV_STATIONS = [
]
+MOCK_PARK_AND_RIDE = TclParkAndRide(
+ id="P+R Gorge de Loup",
+ gid=10,
+ nom="Gorge de Loup",
+ capacite=240,
+ place_handi=6,
+ horaires="24h/24",
+ p_surv=True,
+ nb_tot_place_dispo=42,
+ last_update=datetime(2026, 4, 10, 14, 0),
+ last_update_fme=datetime(2026, 4, 10, 13, 55),
+)
+
+MOCK_PARK_AND_RIDES = [
+ MOCK_PARK_AND_RIDE,
+ TclParkAndRide(
+ id="P+R Oullins La Saulaie",
+ gid=20,
+ nom="Oullins La Saulaie",
+ capacite=420,
+ place_handi=10,
+ horaires="5h-1h",
+ p_surv=False,
+ nb_tot_place_dispo=120,
+ last_update=datetime(2026, 4, 10, 14, 0),
+ last_update_fme=datetime(2026, 4, 10, 13, 55),
+ ),
+]
+
+
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
@@ -175,6 +208,20 @@ def mock_velov_subentries() -> list[ConfigSubentryData]:
]
+@pytest.fixture
+def mock_park_and_ride_subentries() -> list[ConfigSubentryData]:
+ """Mock park-and-ride subentries."""
+ return [
+ ConfigSubentryData(
+ data={CONF_PARK_ID: "P+R Gorge de Loup"},
+ subentry_id="park_1",
+ subentry_type=SUBENTRY_TYPE_PARK_AND_RIDE,
+ title="Gorge de Loup",
+ unique_id="park_and_ride_P+R Gorge de Loup",
+ )
+ ]
+
+
@pytest.fixture
def mock_config_entry(
mock_subentries: list[ConfigSubentryData],
@@ -201,6 +248,19 @@ def mock_velov_config_entry(
)
+@pytest.fixture
+def mock_park_and_ride_config_entry(
+ mock_park_and_ride_subentries: list[ConfigSubentryData],
+) -> MockConfigEntry:
+ """Create a mock config entry with park-and-ride subentries."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ title="Data Grand Lyon",
+ data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
+ subentries_data=mock_park_and_ride_subentries,
+ )
+
+
@pytest.fixture
def mock_tcl_client() -> Generator[AsyncMock]:
"""Mock DataGrandLyonClient for coordinator and config flow."""
@@ -218,4 +278,5 @@ def mock_tcl_client() -> Generator[AsyncMock]:
client.get_tcl_passages.return_value = MOCK_DEPARTURES
client.get_tcl_stops.return_value = MOCK_TCL_STOPS
client.get_velov_stations.return_value = MOCK_VELOV_STATIONS
+ client.get_tcl_park_and_rides.return_value = MOCK_PARK_AND_RIDES
yield client
diff --git a/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr b/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr
index 05986de52c95..83f752ab32be 100644
--- a/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr
+++ b/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr
@@ -32,6 +32,8 @@
'version': 1,
}),
'coordinator_data': dict({
+ 'park_and_rides': dict({
+ }),
'stops': dict({
'stop_1': list([
dict({
@@ -61,6 +63,59 @@
}),
})
# ---
+# name: test_config_entry_diagnostics_with_park_and_ride
+ dict({
+ 'config_entry': dict({
+ 'data': dict({
+ 'password': '**REDACTED**',
+ 'username': '**REDACTED**',
+ }),
+ 'disabled_by': None,
+ 'discovery_keys': dict({
+ }),
+ 'domain': 'data_grand_lyon',
+ 'minor_version': 1,
+ 'options': dict({
+ }),
+ 'pref_disable_new_entities': False,
+ 'pref_disable_polling': False,
+ 'source': 'user',
+ 'subentries': list([
+ dict({
+ 'data': dict({
+ 'park_id': 'P+R Gorge de Loup',
+ }),
+ 'subentry_type': 'park_and_ride',
+ 'title': 'Gorge de Loup',
+ 'unique_id': 'park_and_ride_P+R Gorge de Loup',
+ }),
+ ]),
+ 'title': 'Data Grand Lyon',
+ 'unique_id': None,
+ 'version': 1,
+ }),
+ 'coordinator_data': dict({
+ 'park_and_rides': dict({
+ 'park_1': dict({
+ 'capacite': 240,
+ 'gid': 10,
+ 'horaires': '24h/24',
+ 'id': 'P+R Gorge de Loup',
+ 'last_update': '2026-04-10T14:00:00',
+ 'last_update_fme': '2026-04-10T13:55:00',
+ 'nb_tot_place_dispo': 42,
+ 'nom': 'Gorge de Loup',
+ 'p_surv': True,
+ 'place_handi': 6,
+ }),
+ }),
+ 'stops': dict({
+ }),
+ 'velov_stations': dict({
+ }),
+ }),
+ })
+# ---
# name: test_config_entry_diagnostics_with_velov
dict({
'config_entry': dict({
@@ -93,6 +148,8 @@
'version': 1,
}),
'coordinator_data': dict({
+ 'park_and_rides': dict({
+ }),
'stops': dict({
}),
'velov_stations': dict({
diff --git a/tests/components/data_grand_lyon/snapshots/test_sensor.ambr b/tests/components/data_grand_lyon/snapshots/test_sensor.ambr
index 4820fb4e75d3..9f44f5f8da5e 100644
--- a/tests/components/data_grand_lyon/snapshots/test_sensor.ambr
+++ b/tests/components/data_grand_lyon/snapshots/test_sensor.ambr
@@ -482,6 +482,159 @@
'state': 'unavailable',
})
# ---
+# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_accessible_parking_spaces-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.gorge_de_loup_accessible_parking_spaces',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Accessible parking spaces',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Accessible parking spaces',
+ 'platform': 'data_grand_lyon',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'accessible_spaces',
+ 'unique_id': 'park_and_ride_P+R Gorge de Loup-accessible_spaces',
+ 'unit_of_measurement': 'spaces',
+ })
+# ---
+# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_accessible_parking_spaces-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Gorge de Loup Accessible parking spaces',
+ : 'spaces',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.gorge_de_loup_accessible_parking_spaces',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '6',
+ })
+# ---
+# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_available_parking_spaces-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.gorge_de_loup_available_parking_spaces',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Available parking spaces',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Available parking spaces',
+ 'platform': 'data_grand_lyon',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'available_spaces',
+ 'unique_id': 'park_and_ride_P+R Gorge de Loup-available_spaces',
+ 'unit_of_measurement': 'spaces',
+ })
+# ---
+# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_available_parking_spaces-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Gorge de Loup Available parking spaces',
+ : 'spaces',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.gorge_de_loup_available_parking_spaces',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '42',
+ })
+# ---
+# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_capacity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.gorge_de_loup_capacity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Capacity',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Capacity',
+ 'platform': 'data_grand_lyon',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'park_and_ride_capacity',
+ 'unique_id': 'park_and_ride_P+R Gorge de Loup-capacity',
+ 'unit_of_measurement': 'spaces',
+ })
+# ---
+# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_capacity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Gorge de Loup Capacity',
+ : 'spaces',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.gorge_de_loup_capacity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '240',
+ })
+# ---
# name: test_velov_all_entities[sensor.velo_v_1001_available_bikes-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
diff --git a/tests/components/data_grand_lyon/test_config_flow.py b/tests/components/data_grand_lyon/test_config_flow.py
index a18dfcf03c2e..c64879ef0182 100644
--- a/tests/components/data_grand_lyon/test_config_flow.py
+++ b/tests/components/data_grand_lyon/test_config_flow.py
@@ -8,9 +8,11 @@ import pytest
from homeassistant import config_entries
from homeassistant.components.data_grand_lyon.const import (
CONF_LINE,
+ CONF_PARK_ID,
CONF_STATION_ID,
CONF_STOP_ID,
DOMAIN,
+ SUBENTRY_TYPE_PARK_AND_RIDE,
SUBENTRY_TYPE_STOP,
SUBENTRY_TYPE_VELOV_STATION,
)
@@ -596,3 +598,126 @@ async def test_velov_station_subentry_picker_load_errors(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
+
+
+# Park-and-ride subentry tests
+
+
+@pytest.mark.parametrize("mock_subentries", [[]])
+async def test_park_and_ride_subentry_picker_flow(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test adding a park-and-ride subentry by picking one from the list."""
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.subentries.async_init(
+ (mock_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE),
+ context={"source": config_entries.SOURCE_USER},
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert mock_tcl_client.get_tcl_park_and_rides.await_count == 1
+
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {CONF_PARK_ID: "P+R Gorge de Loup"},
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "Gorge de Loup"
+ assert result["data"] == {CONF_PARK_ID: "P+R Gorge de Loup"}
+ assert result["unique_id"] == "park_and_ride_P+R Gorge de Loup"
+
+
+@pytest.mark.parametrize("mock_subentries", [[]])
+async def test_park_and_ride_subentry_custom_value_flow(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test adding a park-and-ride by typing an ID not present in the list."""
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.subentries.async_init(
+ (mock_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE),
+ context={"source": config_entries.SOURCE_USER},
+ )
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {CONF_PARK_ID: "P+R Unknown"},
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "P+R Unknown"
+ assert result["data"] == {CONF_PARK_ID: "P+R Unknown"}
+ assert result["unique_id"] == "park_and_ride_P+R Unknown"
+
+
+async def test_park_and_ride_subentry_already_configured(
+ hass: HomeAssistant,
+ mock_park_and_ride_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test park-and-ride subentry aborts if same facility already exists."""
+ mock_park_and_ride_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.subentries.async_init(
+ (mock_park_and_ride_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE),
+ context={"source": config_entries.SOURCE_USER},
+ )
+
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {CONF_PARK_ID: "P+R Gorge de Loup"},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
+@pytest.mark.parametrize(
+ ("side_effect", "reason"),
+ [
+ (
+ ClientResponseError(request_info=None, history=(), status=500),
+ "cannot_connect",
+ ),
+ (
+ ClientResponseError(request_info=None, history=(), status=401),
+ "invalid_auth",
+ ),
+ (ClientConnectionError("boom"), "cannot_connect"),
+ (TimeoutError("boom"), "cannot_connect"),
+ (RuntimeError("boom"), "unknown"),
+ ],
+)
+@pytest.mark.parametrize("mock_subentries", [[]])
+async def test_park_and_ride_subentry_picker_load_errors(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+ side_effect: Exception,
+ reason: str,
+) -> None:
+ """Test picker aborts with the right reason when loading park-and-rides fails."""
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ mock_tcl_client.get_tcl_park_and_rides.side_effect = side_effect
+
+ result = await hass.config_entries.subentries.async_init(
+ (mock_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE),
+ context={"source": config_entries.SOURCE_USER},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == reason
diff --git a/tests/components/data_grand_lyon/test_diagnostics.py b/tests/components/data_grand_lyon/test_diagnostics.py
index 28ceda0f7326..424036bac889 100644
--- a/tests/components/data_grand_lyon/test_diagnostics.py
+++ b/tests/components/data_grand_lyon/test_diagnostics.py
@@ -44,3 +44,20 @@ async def test_config_entry_diagnostics_with_velov(
assert await get_diagnostics_for_config_entry(
hass, hass_client, mock_velov_config_entry
) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id"))
+
+
+async def test_config_entry_diagnostics_with_park_and_ride(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ mock_park_and_ride_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test config entry diagnostics with park-and-ride data."""
+ mock_park_and_ride_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert await get_diagnostics_for_config_entry(
+ hass, hass_client, mock_park_and_ride_config_entry
+ ) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id"))
diff --git a/tests/components/data_grand_lyon/test_sensor.py b/tests/components/data_grand_lyon/test_sensor.py
index cfb6706af47b..d00f4b47c235 100644
--- a/tests/components/data_grand_lyon/test_sensor.py
+++ b/tests/components/data_grand_lyon/test_sensor.py
@@ -344,3 +344,121 @@ async def test_coordinator_mixed_partial_failure(
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
+
+
+# Park-and-ride sensor tests
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_park_and_ride_all_entities(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+ mock_park_and_ride_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test all park-and-ride sensor entities (state, attributes, registry)."""
+ with patch("homeassistant.components.data_grand_lyon.PLATFORMS", [Platform.SENSOR]):
+ mock_park_and_ride_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await snapshot_platform(
+ hass, entity_registry, snapshot, mock_park_and_ride_config_entry.entry_id
+ )
+
+
+async def test_park_and_ride_sensor_disabled_by_default(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ mock_park_and_ride_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test that diagnostic park-and-ride sensors are disabled by default."""
+ mock_park_and_ride_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ for unique_id in ("park_and_ride_P+R Gorge de Loup-accessible_spaces",):
+ entry = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id)
+ assert entry is not None, unique_id
+ reg_entry = entity_registry.async_get(entry)
+ assert reg_entry is not None, unique_id
+ assert reg_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
+
+ for unique_id in (
+ "park_and_ride_P+R Gorge de Loup-available_spaces",
+ "park_and_ride_P+R Gorge de Loup-capacity",
+ ):
+ entry = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id)
+ assert entry is not None, unique_id
+ reg_entry = entity_registry.async_get(entry)
+ assert reg_entry is not None, unique_id
+ assert reg_entry.disabled_by is None
+
+
+async def test_park_and_ride_sensor_no_data(
+ hass: HomeAssistant,
+ mock_park_and_ride_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test that park-and-ride sensors are unavailable when facility not found."""
+ mock_tcl_client.get_tcl_park_and_rides.return_value = []
+ mock_park_and_ride_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.gorge_de_loup_available_parking_spaces")
+ assert state is not None
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_coordinator_park_and_ride_auth_error(
+ hass: HomeAssistant,
+ mock_park_and_ride_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test coordinator triggers reauth on park-and-ride auth failure."""
+ mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientResponseError(
+ Mock(), (), status=401
+ )
+ mock_park_and_ride_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_ERROR
+
+ flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
+ assert any(flow["context"].get("source") == SOURCE_REAUTH for flow in flows)
+
+
+async def test_coordinator_park_and_ride_http_error(
+ hass: HomeAssistant,
+ mock_park_and_ride_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test coordinator raises UpdateFailed on non-auth HTTP errors for P+R."""
+ mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientResponseError(
+ Mock(), (), status=500
+ )
+ mock_park_and_ride_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_RETRY
+
+
+async def test_coordinator_park_and_ride_fetch_error(
+ hass: HomeAssistant,
+ mock_park_and_ride_config_entry: MockConfigEntry,
+ mock_tcl_client: AsyncMock,
+) -> None:
+ """Test coordinator raises UpdateFailed on park-and-ride fetch error."""
+ mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientConnectionError(
+ "API down"
+ )
+ mock_park_and_ride_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_RETRY
diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py
index ad036b68e001..bca5b30f9aa2 100644
--- a/tests/components/deconz/conftest.py
+++ b/tests/components/deconz/conftest.py
@@ -87,16 +87,27 @@ def fixture_config_entry_source() -> str:
@pytest.fixture(name="mock_put_request")
def fixture_put_request(
- aioclient_mock: AiohttpClientMocker, config_entry_data: MappingProxyType[str, Any]
-) -> Callable[[str, str], AiohttpClientMocker]:
+ aioclient_mock: AiohttpClientMocker,
+ config_entry_data: MappingProxyType[str, Any],
+) -> Callable[..., AiohttpClientMocker]:
"""Mock a deCONZ put request."""
_host = config_entry_data[CONF_HOST]
_port = config_entry_data[CONF_PORT]
_api_key = config_entry_data[CONF_API_KEY]
- def __mock_requests(path: str, host: str = "") -> AiohttpClientMocker:
+ def __mock_requests(
+ path: str,
+ host: str = "",
+ *,
+ exc: Exception | type[Exception] | None = None,
+ ) -> AiohttpClientMocker:
url = f"http://{host or _host}:{_port}/api/{_api_key}{path}"
- aioclient_mock.put(url, json={}, headers={"content-type": CONTENT_TYPE_JSON})
+ aioclient_mock.put(
+ url,
+ json={},
+ exc=exc,
+ headers={"content-type": CONTENT_TYPE_JSON},
+ )
return aioclient_mock
return __mock_requests
@@ -129,14 +140,17 @@ def fixture_get_request(
sensor_payload = {"0": sensor_payload}
data.setdefault("sensors", sensor_payload)
- def __mock_requests(host: str = "") -> None:
+ def __mock_requests(
+ host: str = "",
+ *,
+ exc: Exception | type[Exception] | None = None,
+ ) -> None:
url = f"http://{host or _host}:{_port}/api/{_api_key}"
aioclient_mock.get(
url,
json=deconz_payload | {"config": config_payload},
- headers={
- "content-type": CONTENT_TYPE_JSON,
- },
+ exc=exc,
+ headers={"content-type": CONTENT_TYPE_JSON},
)
return __mock_requests
diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr
index 884ce49edb69..829e3da21da9 100644
--- a/tests/components/deconz/snapshots/test_hub.ambr
+++ b/tests/components/deconz/snapshots/test_hub.ambr
@@ -2,8 +2,8 @@
# name: test_device_registry_entry
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
'configuration_url': 'http://1.2.3.4:80',
'connections': set({
}),
@@ -24,7 +24,6 @@
'model_id': None,
'name': 'deCONZ mock gateway',
'name_by_user': None,
- 'primary_config_entry': ,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py
index 32a6510db08c..0ebb4bb191d3 100644
--- a/tests/components/deconz/test_services.py
+++ b/tests/components/deconz/test_services.py
@@ -3,6 +3,7 @@
from collections.abc import Callable
from typing import Any
+from pydeconz.errors import RequestError
import pytest
import voluptuous as vol
@@ -22,6 +23,7 @@ from homeassistant.components.deconz.services import (
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .test_hub import BRIDGE_ID
@@ -111,7 +113,8 @@ async def test_configure_service_with_entity_and_field(
@pytest.mark.usefixtures("config_entry_setup")
async def test_configure_service_with_faulty_bridgeid(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that service fails on a bad bridge id."""
aioclient_mock.clear_requests()
@@ -122,9 +125,15 @@ async def test_configure_service_with_faulty_bridgeid(
SERVICE_DATA: {"on": True},
}
- await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data)
- await hass.async_block_till_done()
+ with pytest.raises(HomeAssistantError) as err:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_CONFIGURE_DEVICE,
+ service_data=data,
+ blocking=True,
+ )
+ assert err.value.translation_key == "gateway_not_found"
assert len(aioclient_mock.mock_calls) == 0
@@ -141,9 +150,10 @@ async def test_configure_service_with_faulty_field(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("config_entry_setup")
async def test_configure_service_with_faulty_entity(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
) -> None:
- """Test that service on a non existing entity."""
+ """Test that service fails on a non-existing entity."""
aioclient_mock.clear_requests()
data = {
@@ -151,16 +161,24 @@ async def test_configure_service_with_faulty_entity(
SERVICE_DATA: {},
}
- await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data)
- await hass.async_block_till_done()
+ with pytest.raises(HomeAssistantError) as err:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_CONFIGURE_DEVICE,
+ service_data=data,
+ blocking=True,
+ )
+ assert err.value.translation_key == "entity_not_found"
+ assert err.value.translation_placeholders == {"entity_id": "light.nonexisting"}
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize("config_entry_options", [{CONF_MASTER_GATEWAY: False}])
@pytest.mark.usefixtures("config_entry_setup")
async def test_calling_service_with_no_master_gateway_fails(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that service call fails when no master gateway exist."""
aioclient_mock.clear_requests()
@@ -170,9 +188,15 @@ async def test_calling_service_with_no_master_gateway_fails(
SERVICE_DATA: {"on": True},
}
- await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data)
- await hass.async_block_till_done()
+ with pytest.raises(HomeAssistantError) as err:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_CONFIGURE_DEVICE,
+ service_data=data,
+ blocking=True,
+ )
+ assert err.value.translation_key == "no_master_gateway"
assert len(aioclient_mock.mock_calls) == 0
@@ -390,3 +414,57 @@ async def test_remove_orphaned_entries_service(
)
== 2 # Light and switch battery
)
+
+
+@pytest.mark.usefixtures("config_entry_setup")
+async def test_configure_service_request_error(
+ hass: HomeAssistant,
+ mock_put_request: Callable[..., AiohttpClientMocker],
+) -> None:
+ """Test configure service handles API request errors."""
+
+ data = {
+ SERVICE_FIELD: "/lights/2",
+ CONF_BRIDGE_ID: BRIDGE_ID,
+ SERVICE_DATA: {"on": True},
+ }
+
+ mock_put_request(
+ "/lights/2",
+ exc=RequestError("Request failed"),
+ )
+
+ with pytest.raises(HomeAssistantError) as exc_info:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_CONFIGURE_DEVICE,
+ service_data=data,
+ blocking=True,
+ )
+
+ assert exc_info.value.translation_key == "configure_failed"
+
+
+async def test_service_refresh_devices_failure(
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
+ config_entry_setup: MockConfigEntry,
+ mock_requests: Callable[..., None],
+) -> None:
+ """Test refresh service handles request failures."""
+
+ aioclient_mock.clear_requests()
+ mock_requests(exc=TimeoutError)
+
+ hub = config_entry_setup.runtime_data
+
+ with pytest.raises(HomeAssistantError) as exc_info:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_DEVICE_REFRESH,
+ service_data={CONF_BRIDGE_ID: BRIDGE_ID},
+ blocking=True,
+ )
+
+ assert exc_info.value.translation_key == "device_refresh_failed"
+ assert hub.ignore_state_updates is False
diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py
index e2aca44ee2b1..112af447333b 100644
--- a/tests/components/demo/test_media_player.py
+++ b/tests/components/demo/test_media_player.py
@@ -615,3 +615,31 @@ async def test_browse(
assert msg["result"]["title"] == "media"
assert msg["result"]["media_class"] == "directory"
assert len(msg["result"]["children"])
+
+
+async def test_search(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test the media player search delegates to media source."""
+ entity = "media_player.browse"
+
+ await async_setup_component(hass, "media_source", {"media_source": {}})
+ assert await async_setup_component(
+ hass, MP_DOMAIN, {"media_player": {"platform": "demo"}}
+ )
+ await hass.async_block_till_done()
+
+ websocket_client = await hass_ws_client(hass)
+ await websocket_client.send_json(
+ {
+ "id": 1,
+ "type": "media_player/search_media",
+ "entity_id": entity,
+ "search_query": "test",
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["success"]
+ assert [item["title"] for item in msg["result"]["result"]] == ["test.mp3"]
diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py
index f5330670ddd0..0208c1e9dce1 100644
--- a/tests/components/derivative/test_init.py
+++ b/tests/components/derivative/test_init.py
@@ -137,18 +137,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
derivative_config_entry: MockConfigEntry,
- sensor_config_entry: ConfigEntry,
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
- """Test the derivative config entry is removed when the source entity is removed."""
- # Add another config entry to the sensor device
- other_config_entry = MockConfigEntry()
- other_config_entry.add_to_hass(hass)
- device_registry.async_update_device(
- sensor_device.id, add_config_entry_id=other_config_entry.entry_id
- )
-
+ """Test the source device is not removed when the source entity is removed."""
assert await hass.config_entries.async_setup(derivative_config_entry.entry_id)
await hass.async_block_till_done()
@@ -160,15 +152,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id)
- # Remove the source sensor's config entry from the device, this removes the
- # source sensor
+ # Remove the source sensor
with patch(
"homeassistant.components.derivative.async_unload_entry",
wraps=derivative.async_unload_entry,
) as mock_unload_entry:
- device_registry.async_update_device(
- sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id
- )
+ entity_registry.async_remove(sensor_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_not_called()
@@ -177,8 +166,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
derivative_entity_entry = entity_registry.async_get("sensor.my_derivative")
assert derivative_entity_entry.device_id is None
- # Check that the derivative config entry is not in the device
+ # Check that the source device is not removed
sensor_device = device_registry.async_get(sensor_device.id)
+ assert sensor_device is not None
assert derivative_config_entry.entry_id not in sensor_device.config_entries
# Check that the derivative config entry is not removed
@@ -380,7 +370,7 @@ async def test_migration_1_2(
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
- """Test migration from v1.2 removes derivative config entry from device."""
+ """Test migration from v1.2 keeps the derivative entity linked to the source device."""
derivative_config_entry = MockConfigEntry(
data={},
@@ -399,22 +389,13 @@ async def test_migration_1_2(
)
derivative_config_entry.add_to_hass(hass)
- # Add the helper config entry to the device
- device_registry.async_update_device(
- sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id
- )
-
- # Check preconditions
- sensor_device = device_registry.async_get(sensor_device.id)
- assert derivative_config_entry.entry_id in sensor_device.config_entries
-
await hass.config_entries.async_setup(derivative_config_entry.entry_id)
await hass.async_block_till_done()
assert derivative_config_entry.state is ConfigEntryState.LOADED
- # Check that the helper config entry is removed from the device and the helper
- # entity is linked to the source device
+ # Check that the derivative config entry is not on the source device and the
+ # derivative entity is linked to the source device
sensor_device = device_registry.async_get(sensor_device.id)
assert derivative_config_entry.entry_id not in sensor_device.config_entries
derivative_entity_entry = entity_registry.async_get("sensor.my_derivative")
diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py
index d54da57b38af..367a327b81a7 100644
--- a/tests/components/device_automation/test_init.py
+++ b/tests/components/device_automation/test_init.py
@@ -1,5 +1,6 @@
"""The test for light device automation."""
+from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import attr
@@ -11,14 +12,23 @@ from homeassistant import loader
from homeassistant.components import automation, device_automation
from homeassistant.components.device_automation import (
DOMAIN,
+ DeviceAutomationType,
InvalidDeviceAutomationConfig,
toggle_entity,
)
+from homeassistant.components.device_automation.helpers import (
+ _resolve_device_id,
+ async_validate_device_automation_config,
+)
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers import (
+ area_registry as ar,
+ device_registry as dr,
+ entity_registry as er,
+)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound
from homeassistant.requirements import RequirementsNotFound
@@ -1745,3 +1755,137 @@ async def test_async_get_device_automations_platform_reraises_exceptions(
await device_automation.async_get_device_automation_platform(
hass, "test", device_automation.DeviceAutomationType.TRIGGER
)
+
+
+COMPOSITE_ID = "composite0000000000000000000000"
+
+
+@pytest.mark.parametrize("load_registries", [False])
+async def test_device_automation_resolves_legacy_id(
+ hass: HomeAssistant, hass_storage: dict[str, Any]
+) -> None:
+ """A device automation legacy id resolves to the split owning its domain's entry.
+
+ Automations for an entity platform domain are left as the composite id, which the
+ restored composite device and async_entries_for_device handle directly.
+ """
+ entry_a = MockConfigEntry(domain="domain_a")
+ entry_a.add_to_hass(hass)
+ entry_b = MockConfigEntry(domain="domain_b")
+ entry_b.add_to_hass(hass)
+ hass_storage[dr.STORAGE_KEY] = {
+ "version": 1,
+ "minor_version": 10,
+ "data": {
+ "devices": [
+ {
+ "area_id": "area_1",
+ "config_entries": [entry_a.entry_id, entry_b.entry_id],
+ "config_entries_subentries": {
+ entry_a.entry_id: [None],
+ entry_b.entry_id: [None],
+ },
+ "configuration_url": None,
+ "connections": [["mac", "12:34:56:ab:cd:ef"]],
+ "created_at": "1970-01-01T00:00:00+00:00",
+ "disabled_by": None,
+ "entry_type": None,
+ "hw_version": None,
+ "id": COMPOSITE_ID,
+ "identifiers": [["domain_a", "1"], ["domain_b", "1"]],
+ "labels": ["lab"],
+ "manufacturer": "man",
+ "model": "mod",
+ "name": "composite",
+ "model_id": None,
+ "modified_at": "1970-01-01T00:00:00+00:00",
+ "name_by_user": "custom name",
+ "primary_config_entry": entry_a.entry_id,
+ "serial_number": "SERIAL",
+ "sw_version": None,
+ "via_device_id": None,
+ }
+ ],
+ "deleted_devices": [],
+ },
+ }
+
+ dr.async_setup(hass)
+ await dr.async_load(hass)
+ await er.async_load(hass)
+ await ar.async_load(hass)
+ device_registry = dr.async_get(hass)
+ entity_registry = er.async_get(hass)
+ by_entry = {
+ d.config_entry_id: d.id
+ for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID)
+ }
+
+ # A config-entry domain resolves to the split owning that domain's config entry
+ assert (
+ _resolve_device_id(hass, COMPOSITE_ID, "domain_a") == by_entry[entry_a.entry_id]
+ )
+ assert (
+ _resolve_device_id(hass, COMPOSITE_ID, "domain_b") == by_entry[entry_b.entry_id]
+ )
+
+ # An entity platform domain is left unresolved, even when a split has such entities
+ entity_registry.async_get_or_create(
+ "light",
+ "domain_a",
+ "unique",
+ config_entry=entry_a,
+ device_id=by_entry[entry_a.entry_id],
+ )
+ assert _resolve_device_id(hass, COMPOSITE_ID, "light") == COMPOSITE_ID
+
+ # An unknown domain is returned unchanged
+ assert _resolve_device_id(hass, COMPOSITE_ID, "not_present") == COMPOSITE_ID
+
+
+async def test_validate_config_rewrites_composite_device_id(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+ fake_integration: None,
+) -> None:
+ """Validating a device automation rewrites a composite id to its domain's split."""
+ fake_entry = MockConfigEntry(domain="fake_integration")
+ fake_entry.add_to_hass(hass)
+ other_entry = MockConfigEntry(domain="other")
+ other_entry.add_to_hass(hass)
+ device_fake = device_registry.async_get_or_create(
+ config_entry_id=fake_entry.entry_id, identifiers={("fake_integration", "1")}
+ )
+ device_other = device_registry.async_get_or_create(
+ config_entry_id=other_entry.entry_id, identifiers={("other", "1")}
+ )
+ entity = entity_registry.async_get_or_create(
+ "light", "fake_integration", "u", device_id=device_fake.id
+ )
+ old_id = "composite00000000000000000000ab"
+ # Simulate a migration split: both devices carry the pre-migration composite id
+ device_registry.devices[device_fake.id] = attr.evolve(
+ device_fake, composite_device_id=old_id
+ )
+ device_registry.devices[device_other.id] = attr.evolve(
+ device_other, composite_device_id=old_id
+ )
+ assert old_id not in device_registry.devices
+
+ validated = await async_validate_device_automation_config(
+ hass,
+ {
+ "platform": "device",
+ "domain": "fake_integration",
+ "device_id": old_id,
+ "entity_id": entity.entity_id,
+ "type": "turned_on",
+ },
+ vol.Schema(
+ {vol.Required("device_id"): str, vol.Required("domain"): str},
+ extra=vol.ALLOW_EXTRA,
+ ),
+ DeviceAutomationType.TRIGGER,
+ )
+ assert validated["device_id"] == device_fake.id
diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py
index 230398378a0e..c2ffa6bcfe4a 100644
--- a/tests/components/device_tracker/test_entity.py
+++ b/tests/components/device_tracker/test_entity.py
@@ -3,6 +3,7 @@
from collections.abc import Generator
from typing import Any
+import attr
import pytest
from homeassistant.components.device_tracker import (
@@ -1611,6 +1612,96 @@ async def test_register_mac_ignored(
assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION
+async def test_scanner_entity_attaches_to_split_of_composite_device(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test that a scanner entity attaches to its config entry's split device."""
+ mac = TEST_MAC_ADDRESS
+ other_entry = MockConfigEntry(domain="other")
+ other_entry.add_to_hass(hass)
+ old_id = "composite00000000000000000000000"
+ own_split = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ identifiers={(TEST_DOMAIN, "own")},
+ )
+ other_split = device_registry.async_get_or_create(
+ config_entry_id=other_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ identifiers={("other", "x")},
+ )
+ # Simulate a migration split: both devices share the pre-migration composite id
+ device_registry.devices[own_split.id] = attr.evolve(
+ own_split, composite_device_id=old_id
+ )
+ device_registry.devices[other_split.id] = attr.evolve(
+ other_split, composite_device_id=old_id
+ )
+ # async_get_device now resolves the shared MAC to the synthesized composite
+ composite = device_registry.async_get_device(
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)}
+ )
+ assert composite is not None
+ assert composite.id == old_id
+ assert old_id not in device_registry.devices
+
+ scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner")
+ scanner_entity.entity_id = "device_tracker.composite_scanner"
+ await create_mock_platform(hass, config_entry, [scanner_entity])
+
+ # Attached to its own split, not the un-assignable composite id
+ entity_entry = entity_registry.async_get("device_tracker.composite_scanner")
+ assert entity_entry is not None
+ assert entity_entry.device_id == own_split.id
+
+
+async def test_scanner_entity_composite_device_without_own_split(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """A composite with no split owned by the scanner's config entry attaches nothing.
+
+ The composite id is not a real device and can't be assigned to an entity, so with no
+ split to resolve to the entity is added without a device instead of raising.
+ """
+ mac = TEST_MAC_ADDRESS
+ other_entry_1 = MockConfigEntry(domain="other_1")
+ other_entry_1.add_to_hass(hass)
+ other_entry_2 = MockConfigEntry(domain="other_2")
+ other_entry_2.add_to_hass(hass)
+ old_id = "composite00000000000000000000000"
+ # Both splits belong to other config entries, none to the scanner's
+ for entry, identifier in ((other_entry_1, "one"), (other_entry_2, "two")):
+ split = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ identifiers={("other", identifier)},
+ )
+ device_registry.devices[split.id] = attr.evolve(
+ split, composite_device_id=old_id
+ )
+ composite = device_registry.async_get_device(
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)}
+ )
+ assert composite is not None
+ assert composite.id == old_id
+ assert old_id not in device_registry.devices
+
+ scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner")
+ scanner_entity.entity_id = "device_tracker.composite_scanner"
+ await create_mock_platform(hass, config_entry, [scanner_entity])
+
+ # Added without a device rather than raising on the un-assignable composite id
+ entity_entry = entity_registry.async_get("device_tracker.composite_scanner")
+ assert entity_entry is not None
+ assert entity_entry.device_id is None
+
+
async def test_connected_device_registered(
hass: HomeAssistant,
config_entry: MockConfigEntry,
diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr
index 69cf0adba2b5..d4539a2dfdd1 100644
--- a/tests/components/devolo_home_network/snapshots/test_init.ambr
+++ b/tests/components/devolo_home_network/snapshots/test_init.ambr
@@ -2,8 +2,8 @@
# name: test_device[mock_device]
DeviceRegistryEntrySnapshot({
'area_id': None,
- 'config_entries': ,
- 'config_entries_subentries': ,
+ 'config_entry_id': ,
+ 'config_subentry_id':