diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json
new file mode 100644
index 000000000000..7dfd04e82067
--- /dev/null
+++ b/.github/aw/actions-lock.json
@@ -0,0 +1,14 @@
+{
+ "entries": {
+ "github/gh-aw-actions/setup-cli@v0.83.4": {
+ "repo": "github/gh-aw-actions/setup-cli",
+ "version": "v0.83.4",
+ "sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7"
+ },
+ "github/gh-aw-actions/setup@v0.83.4": {
+ "repo": "github/gh-aw-actions/setup",
+ "version": "v0.83.4",
+ "sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7"
+ }
+ }
+}
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 8991307a9233..7d64835dc667 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -182,6 +182,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before. Comments in tests that explain why a function call or assertion is made are ok.
- Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading.
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
+- Sensitive service actions, i.e. those that can change configuration or have security implications, should require an admin user. Register them with the `async_register_admin_service` service helper, which checks this for you.
## AI policy
diff --git a/.github/workflows/aw.json b/.github/workflows/aw.json
new file mode 100644
index 000000000000..518d5ef22718
--- /dev/null
+++ b/.github/workflows/aw.json
@@ -0,0 +1,3 @@
+{
+ "maintenance": false
+}
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index 6da3f760b46b..5e3f0b125635 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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
+ uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
+ uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -521,7 +521,7 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
- uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
+ uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
diff --git a/.github/workflows/check-requirements-deterministic.yml b/.github/workflows/check-requirements-deterministic.yml
index 7f2456b27960..615c872d242b 100644
--- a/.github/workflows/check-requirements-deterministic.yml
+++ b/.github/workflows/check-requirements-deterministic.yml
@@ -10,6 +10,8 @@ name: Check requirements (deterministic)
on:
pull_request:
types: [opened, synchronize, reopened]
+ branches-ignore:
+ - master
paths:
- "requirements*.txt"
- "**/requirements*.txt"
diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml
index e42acd40e14f..6d4c298e5e70 100644
--- a/.github/workflows/check-requirements.lock.yml
+++ b/.github/workflows/check-requirements.lock.yml
@@ -1,20 +1,21 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"36a7fc263a2ce868d74a266f23eb7772d82fd397806464384fe087479ddd4a70","body_hash":"bba8c011f2b82bb4d9847a359f43f0e7d91245b280678c20e5112b3c9e77d5cd","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
-# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"5c2fe865bb4dc46e1450f6ee0d0541d759aea73a","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
-# ___ _ _
-# / _ \ | | (_)
-# | |_| | __ _ ___ _ __ | |_ _ ___
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"171579373098bfb339df0be86af6a29fc49de683760acbf795639518392f52c6","body_hash":"bba8c011f2b82bb4d9847a359f43f0e7d91245b280678c20e5112b3c9e77d5cd","compiler_version":"v0.83.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}}
+# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"e89c65e17eb281bbd5ff2ff9e9199a03e96654c7","version":"v0.83.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]}
+# This file was automatically generated by gh-aw (v0.83.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
+#
+# ___ _ _
+# / _ \ | | (_)
+# | |_| | __ _ ___ _ __ | |_ _ ___
# | _ |/ _` |/ _ \ '_ \| __| |/ __|
-# | | | | (_| | __/ | | | |_| | (__
+# | | | | (_| | __/ | | | |_| | (__
# \_| |_/\__, |\___|_| |_|\__|_|\___|
# __/ |
-# _ _ |___/
+# _ _ |___/
# | | | | / _| |
# | | | | ___ _ __ _ __| |_| | _____ ____
# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
#
-# This file was automatically generated by gh-aw (v0.79.6). DO NOT EDIT.
#
# To update this file, edit the corresponding .md file and run:
# gh aw compile
@@ -31,28 +32,33 @@
# - GITHUB_TOKEN
#
# Custom actions used:
+# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
+# - github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
#
# Container images used:
-# - ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6
-# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4
-# - ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591
-# - ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa
-# - ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c
+# - ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b
+# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607
+# - ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0
+# - ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c
+# - ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748
+# - ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308
name: "Check requirements (AW)"
on:
+ # roles: all # Roles processed as role check in pre-activation job
workflow_run:
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
types:
- - completed
+ - completed
workflows:
- - Check requirements (deterministic)
+ - Check requirements (deterministic)
permissions: {}
@@ -64,26 +70,27 @@ run-name: "Check requirements (AW)"
jobs:
activation:
- needs: pre_activation
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
if: >
- (needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
- (!(github.event.workflow_run.repository.fork)))
+ ${{ github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
+ (!(github.event.workflow_run.repository.fork)) }}
runs-on: ubuntu-slim
permissions:
actions: read
contents: read
env:
GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
+ GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
outputs:
comment_id: ""
comment_repo: ""
- daily_effective_workflow_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_exceeded == 'true' }}
- daily_effective_workflow_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_threshold || '' }}
- daily_effective_workflow_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_total_effective_tokens || '' }}
+ daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }}
+ daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }}
+ daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }}
engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
model: ${{ steps.generate_aw_info.outputs.model }}
+ oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }}
secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
setup-span-id: ${{ steps.setup.outputs.span-id }}
@@ -92,18 +99,16 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
+ uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
- trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }}
safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.60"
- GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_VERSION: "1.0.75"
+ GH_AW_INFO_AWF_VERSION: "v0.27.42"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Generate agentic run info
id: generate_aw_info
@@ -111,16 +116,16 @@ jobs:
GH_AW_INFO_ENGINE_ID: "copilot"
GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
- GH_AW_INFO_VERSION: "1.0.60"
- GH_AW_INFO_AGENT_VERSION: "1.0.60"
- GH_AW_INFO_CLI_VERSION: "v0.79.6"
+ GH_AW_INFO_VERSION: "1.0.75"
+ GH_AW_INFO_AGENT_VERSION: "1.0.75"
+ GH_AW_INFO_CLI_VERSION: "v0.83.4"
GH_AW_INFO_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_INFO_EXPERIMENTAL: "false"
GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
GH_AW_INFO_STAGED: "false"
GH_AW_INFO_ALLOWED_DOMAINS: '["python"]'
GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_AWF_VERSION: "v0.27.42"
GH_AW_INFO_AWMG_VERSION: ""
GH_AW_INFO_FIREWALL_TYPE: "squid"
GH_AW_COMPILED_STRICT: "true"
@@ -131,6 +136,30 @@ jobs:
setupGlobals(core, github, context, exec, io, getOctokit);
const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
await main(core, context);
+ - name: Restore daily AIC usage cache
+ id: restore-daily-aic-cache
+ if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
+ continue-on-error: true
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ key: agentic-workflow-usage-checkrequirements-${{ github.run_id }}
+ restore-keys: agentic-workflow-usage-checkrequirements-
+ path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
+ - name: Restore daily AIC usage cache (artifact fallback)
+ id: restore-daily-aic-cache-fallback
+ if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
+ continue-on-error: true
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }}
+ GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }}
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs');
+ await main();
- name: Check daily workflow token guardrail
id: daily-effective-workflow-guardrail
if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
@@ -140,6 +169,8 @@ jobs:
GH_AW_WORKFLOW_ID: "check-requirements"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
+ GH_AW_HAS_SLASH_COMMAND: "false"
+ GH_AW_HAS_LABEL_COMMAND: "false"
GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
with:
@@ -154,6 +185,13 @@ jobs:
run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ - name: Check for OAuth tokens
+ id: check-oauth-tokens
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh"
+ env:
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -164,7 +202,6 @@ jobs:
.antigravity
.claude
.codex
- .crush
.gemini
.opencode
.pi
@@ -172,8 +209,8 @@ jobs:
fetch-depth: 1
- name: Save agent config folders for base branch restoration
env:
- GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi"
- GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
+ GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi"
+ GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
# poutine:ignore untrusted_checkout_exec
run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
- name: Check workflow lock file
@@ -191,13 +228,16 @@ jobs:
- name: Check compile-agentic version
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
- GH_AW_COMPILED_VERSION: "v0.79.6"
+ GH_AW_COMPILED_VERSION: "v0.83.4"
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io, getOctokit);
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs');
await main();
+ - name: Log runtime features
+ if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }}
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh"
- name: Create prompt with built-in context
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
@@ -214,20 +254,20 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
- cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
+ cat << 'GH_AW_PROMPT_b9403b48a8e785a5_EOF'
- GH_AW_PROMPT_781cf5b2f30d6d93_EOF
+ GH_AW_PROMPT_b9403b48a8e785a5_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
+ cat << 'GH_AW_PROMPT_b9403b48a8e785a5_EOF'
Tools: add_comment, missing_tool, missing_data, noop
- GH_AW_PROMPT_781cf5b2f30d6d93_EOF
+ GH_AW_PROMPT_b9403b48a8e785a5_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
+ cat << 'GH_AW_PROMPT_b9403b48a8e785a5_EOF'
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -255,13 +295,13 @@ jobs:
- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
{{/if}}
-
- GH_AW_PROMPT_781cf5b2f30d6d93_EOF
+
+ GH_AW_PROMPT_b9403b48a8e785a5_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
+ cat << 'GH_AW_PROMPT_b9403b48a8e785a5_EOF'
{{#runtime-import .github/workflows/check-requirements.md}}
- GH_AW_PROMPT_781cf5b2f30d6d93_EOF
+ GH_AW_PROMPT_b9403b48a8e785a5_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -286,15 +326,14 @@ jobs:
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` — run `safeoutputs --help` to see available tools'
- GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
+ GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools"
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io, getOctokit);
-
+
const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
-
+
// Call the substitution function
return await substitutePlaceholders({
file: process.env.GH_AW_PROMPT,
@@ -307,8 +346,7 @@ jobs:
GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST,
- GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
+ GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST
}
});
- name: Validate prompt placeholders
@@ -329,7 +367,6 @@ jobs:
include-hidden-files: true
path: |
/tmp/gh-aw/aw_info.json
- /tmp/gh-aw/model_multipliers.json
/tmp/gh-aw/models.json
/tmp/gh-aw/aw-prompts/prompt.txt
/tmp/gh-aw/aw-prompts/prompt-template.txt
@@ -345,7 +382,7 @@ jobs:
needs:
- activation
- prepare
- if: (needs.prepare.outputs.skip != 'true') && (needs.activation.outputs.daily_effective_workflow_exceeded != 'true')
+ if: (needs.prepare.outputs.skip != 'true') && (needs.activation.outputs.daily_ai_credits_exceeded != 'true')
runs-on: ubuntu-latest
permissions:
actions: read
@@ -360,6 +397,7 @@ jobs:
GH_AW_ASSETS_BRANCH: ""
GH_AW_ASSETS_MAX_SIZE_KB: 0
GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
GH_AW_WORKFLOW_ID_SANITIZED: checkrequirements
outputs:
agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
@@ -369,7 +407,9 @@ jobs:
checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
has_patch: ${{ steps.collect_output.outputs.has_patch }}
+ http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }}
inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
+ invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }}
mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
model: ${{ needs.activation.outputs.model }}
model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
@@ -382,7 +422,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
+ uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -391,8 +431,8 @@ jobs:
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.60"
- GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_VERSION: "1.0.75"
+ GH_AW_INFO_AWF_VERSION: "v0.27.42"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Set runtime paths
id: set-runtime-paths
@@ -412,6 +452,11 @@ jobs:
run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
env:
GH_TOKEN: ${{ github.token }}
+ - name: Download activation artifact
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: activation
+ path: /tmp/gh-aw
- if: github.event.workflow_run.conclusion == 'success'
name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -423,17 +468,10 @@ jobs:
- name: Configure Git credentials
env:
- REPO_NAME: ${{ github.repository }}
- SERVER_URL: ${{ github.server_url }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_TOKEN: ${{ github.token }}
- run: |
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
- git config --global user.name "github-actions[bot]"
- git config --global am.keepcr true
- # Re-authenticate git with GitHub token
- SERVER_URL_STRIPPED="${SERVER_URL#https://}"
- git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
- echo "Git configured with standard GitHub Actions identity"
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- name: Checkout PR branch
id: checkout-pr
if: |
@@ -449,11 +487,22 @@ jobs:
const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
await main();
- name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.75
env:
GH_HOST: github.com
- name: Install AWF binary
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.42 --rootless
+ - name: Determine automatic lockdown mode for GitHub MCP Server
+ id: determine-automatic-lockdown
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
+ env:
+ GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
+ GH_AW_GITHUB_MIN_INTEGRITY: 'unapproved'
+ with:
+ script: |
+ const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
+ await determineAutomaticLockdown(github, context, core);
- name: Parse integrity filter lists
id: parse-guard-vars
env:
@@ -461,16 +510,11 @@ jobs:
GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_USERS || '' }}
GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}
run: bash "${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh"
- - name: Download activation artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: activation
- path: /tmp/gh-aw
- name: Restore agent config folders from base branch
if: steps.checkout-pr.outcome == 'success'
env:
- GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi"
- GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
+ GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi"
+ GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh"
- name: Restore inline sub-agents from activation artifact
env:
@@ -482,15 +526,15 @@ jobs:
GH_AW_SKILL_DIR: ".github/skills"
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh"
- name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591 ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607 ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0 ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748 ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308
- name: Generate Safe Outputs Config
run: |
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_823c5547a5e52957_EOF'
+ cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_8ec65654fdb08fbe_EOF'
{"add_comment":{"max":1,"target":"${{ needs.prepare.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_823c5547a5e52957_EOF
+ GH_AW_SAFE_OUTPUTS_CONFIG_8ec65654fdb08fbe_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
@@ -606,60 +650,22 @@ jobs:
setupGlobals(core, github, context, exec, io, getOctokit);
const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
await main();
- - name: Generate Safe Outputs MCP Server Config
- id: safe-outputs-config
- run: |
- # Generate a secure random API key (360 bits of entropy, 40+ chars)
- # Mask immediately to prevent timing vulnerabilities
- API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${API_KEY}"
-
- PORT=3001
-
- # Set outputs for next steps
- {
- echo "safe_outputs_api_key=${API_KEY}"
- echo "safe_outputs_port=${PORT}"
- } >> "$GITHUB_OUTPUT"
-
- echo "Safe Outputs MCP server will run on port ${PORT}"
-
- - name: Start Safe Outputs MCP HTTP Server
- id: safe-outputs-start
- env:
- DEBUG: '*'
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }}
- GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }}
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- run: |
- # Environment variables are set above to prevent template injection
- export DEBUG
- export GH_AW_SAFE_OUTPUTS
- export GH_AW_SAFE_OUTPUTS_PORT
- export GH_AW_SAFE_OUTPUTS_API_KEY
- export GH_AW_SAFE_OUTPUTS_TOOLS_PATH
- export GH_AW_SAFE_OUTPUTS_CONFIG_PATH
- export GH_AW_MCP_LOG_DIR
-
- bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh"
-
- name: Start MCP Gateway
id: start-mcp-gateway
env:
+ GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }}
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
- GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }}
+ GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }}
GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eo pipefail
mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config"
-
+
# Export gateway environment variables for MCP config and gateway script
export MCP_GATEWAY_PORT="8080"
- export MCP_GATEWAY_DOMAIN="host.docker.internal"
+ export MCP_GATEWAY_DOMAIN="awmg-mcpg"
export MCP_GATEWAY_HOST_DOMAIN="localhost"
MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
echo "::add-mask::${MCP_GATEWAY_API_KEY}"
@@ -668,29 +674,25 @@ jobs:
mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
export DEBUG="*"
-
+
export GH_AW_ENGINE="copilot"
MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0')
MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0')
- case "${DOCKER_HOST:-}" in
- unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;;
- /* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;;
- * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;;
- esac
- DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0')
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.25'
-
- mkdir -p /home/runner/.copilot
+ source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh"
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.6'
+
+ mkdir -p "$HOME/.copilot"
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_f09adf73c5e58a42_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
+ cat << GH_AW_MCP_CONFIG_f55de5a807caee8b_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
{
"mcpServers": {
"github": {
"type": "stdio",
- "container": "ghcr.io/github/github-mcp-server:v1.1.2",
+ "container": "ghcr.io/github/github-mcp-server:v1.7.0",
"env": {
- "GITHUB_HOST": "\${GITHUB_SERVER_URL}",
- "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
+ "GITHUB_FEATURES": "fields_param",
+ "GITHUB_HOST": "${GITHUB_SERVER_URL}",
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}",
"GITHUB_READ_ONLY": "1",
"GITHUB_TOOLSETS": "repos,pull_requests"
},
@@ -705,16 +707,35 @@ jobs:
}
},
"safeoutputs": {
- "type": "http",
- "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
- "headers": {
- "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}"
+ "type": "stdio",
+ "container": "ghcr.io/github/gh-aw-node",
+ "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"],
+ "args": ["-w", "\${GITHUB_WORKSPACE}"],
+ "entrypoint": "sh",
+ "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"],
+ "env": {
+ "DEBUG": "*",
+ "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}",
+ "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}",
+ "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}",
+ "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}",
+ "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}",
+ "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}",
+ "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}",
+ "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}",
+ "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}",
+ "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}",
+ "GITHUB_SHA": "\${GITHUB_SHA}",
+ "GITHUB_TOKEN": "\${GITHUB_TOKEN}",
+ "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}",
+ "RUNNER_TEMP": "\${RUNNER_TEMP}"
},
"guard-policies": {
"write-sink": {
"accept": [
"*"
- ]
+ ],
+ "sink-visibility": ${{ toJSON(steps.determine-automatic-lockdown.outputs.visibility) }}
}
}
}
@@ -723,10 +744,11 @@ jobs:
"port": $MCP_GATEWAY_PORT,
"domain": "${MCP_GATEWAY_DOMAIN}",
"apiKey": "${MCP_GATEWAY_API_KEY}",
- "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
+ "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}",
+ "startupTimeout": 120
}
}
- GH_AW_MCP_CONFIG_f09adf73c5e58a42_EOF
+ GH_AW_MCP_CONFIG_f55de5a807caee8b_EOF
- name: Mount MCP servers as CLIs
id: mount-mcp-clis
continue-on-error: true
@@ -755,48 +777,51 @@ jobs:
run: |
set -o pipefail
printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'rm -f /home/runner/.copilot/settings.json' EXIT
- mkdir -p /home/runner/.copilot
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > /home/runner/.copilot/settings.json
+ trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
+ mkdir -p "$HOME/.copilot"
+ printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
+ export XDG_CONFIG_HOME="$HOME"
+ export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"
touch /tmp/gh-aw/agent-step-summary.md
GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
export GH_AW_NODE_BIN
export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
(umask 177 && touch /tmp/gh-aw/agent-stdio.log)
- GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}"
- printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.pythonhosted.org\",\"anaconda.org\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"binstar.org\",\"bootstrap.pypa.io\",\"conda.anaconda.org\",\"conda.binstar.org\",\"files.pythonhosted.org\",\"github.com\",\"host.docker.internal\",\"pip.pypa.io\",\"pypi.org\",\"pypi.python.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"repo.anaconda.com\",\"repo.continuum.io\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs"
+ GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}"
+ printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.42/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.pythonhosted.org\",\"anaconda.org\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"binstar.org\",\"bootstrap.pypa.io\",\"conda.anaconda.org\",\"conda.binstar.org\",\"files.pythonhosted.org\",\"github.com\",\"host.docker.internal\",\"pip.pypa.io\",\"pypi.org\",\"pypi.python.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"repo.anaconda.com\",\"repo.continuum.io\",\"telemetry.enterprise.githubcopilot.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"fable\",\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.42,squid=sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0,agent=sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b,agent-act=sha256:a14ad974484aa518aab83d40f3f141175dfd171d3745e01c092375b970f73a20,api-proxy=sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607,cli-proxy=sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""
+ GH_AW_DOCKER_HOST=""
if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"
+ GH_AW_DOCKER_HOST="${DOCKER_HOST}"
+ fi
+ if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
+ GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"
fi
GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"
+ GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
if [ -d "$GH_AW_TOOL_CACHE" ]; then
if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
fi
- elif [ -d "/home/runner/work/_tool" ]; then
- GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro"
fi
- # shellcheck disable=SC1003
- sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
- -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ # shellcheck disable=SC1003,SC2016,SC2086
+ awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \
+ -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
env:
AWF_REFLECT_ENABLED: 1
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
+ GH_AW_LLM_PROVIDER: github
+ GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
- GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json
GH_AW_PHASE: agent
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
GH_AW_TIMEOUT_MINUTES: 20
- GH_AW_VERSION: v0.79.6
+ GH_AW_VERSION: v0.83.4
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_AW: true
GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
@@ -811,7 +836,7 @@ jobs:
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
RUNNER_TEMP: ${{ runner.temp }}
- XDG_CONFIG_HOME: /home/runner
+ TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- name: Detect agent errors
if: always()
id: detect-agent-errors
@@ -819,17 +844,10 @@ jobs:
run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
- name: Configure Git credentials
env:
- REPO_NAME: ${{ github.repository }}
- SERVER_URL: ${{ github.server_url }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_TOKEN: ${{ github.token }}
- run: |
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
- git config --global user.name "github-actions[bot]"
- git config --global am.keepcr true
- # Re-authenticate git with GitHub token
- SERVER_URL_STRIPPED="${SERVER_URL#https://}"
- git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
- echo "Git configured with standard GitHub Actions identity"
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- name: Copy Copilot session state files to logs
if: always()
continue-on-error: true
@@ -888,6 +906,7 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
@@ -909,16 +928,7 @@ jobs:
continue-on-error: true
env:
AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
- run: |
- # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts
- # AWF runs with sudo, creating files owned by root
- sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true
- # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step)
- if command -v awf &> /dev/null; then
- awf logs summary | tee -a "$GITHUB_STEP_SUMMARY"
- else
- echo 'AWF binary not installed, skipping firewall log summary'
- fi
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless
- name: Parse token usage for step summary
if: always()
continue-on-error: true
@@ -997,17 +1007,19 @@ jobs:
- safe_outputs
if: >
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
- needs.activation.outputs.stale_lock_file_failed == 'true' || needs.activation.outputs.daily_effective_workflow_exceeded == 'true')
+ needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' ||
+ needs.activation.outputs.secret_verification_result == 'failed' || needs.activation.outputs.daily_ai_credits_exceeded == 'true')
runs-on: ubuntu-slim
permissions:
contents: read
- discussions: write
issues: write
pull-requests: write
concurrency:
group: "gh-aw-conclusion-check-requirements"
cancel-in-progress: false
queue: max
+ env:
+ GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
outputs:
incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
noop_message: ${{ steps.noop.outputs.noop_message }}
@@ -1016,7 +1028,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
+ uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1025,8 +1037,8 @@ jobs:
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.60"
- GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_VERSION: "1.0.75"
+ GH_AW_INFO_AWF_VERSION: "v0.27.42"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Download agent output artifact
id: download-agent-output
@@ -1042,26 +1054,40 @@ jobs:
mkdir -p /tmp/gh-aw/
find "/tmp/gh-aw/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Download safe outputs items manifest
+ id: download-safe-outputs-manifest
+ if: always()
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: safe-outputs-items
+ path: /tmp/gh-aw/
- name: Collect usage artifact files
if: always()
continue-on-error: true
run: |
mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection
echo "Usage artifact source file status:"
- for file in /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do
+ for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do
[ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file"
done
+ [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true
[ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true
+ [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true
[ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true
[ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true
- [ -f /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
- [ -f /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
- [ -f /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
- [ -f /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
- [ -f /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
- [ -f /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true
+ [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
[ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl
[ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl
+ mkdir -p /tmp/gh-aw/usage/activity
+ node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs"
find /tmp/gh-aw/usage -type f -print | sort
- name: Upload usage artifact
if: always()
@@ -1070,12 +1096,56 @@ jobs:
with:
name: usage
path: |
+ /tmp/gh-aw/usage/aw_info.json
/tmp/gh-aw/usage/aw-info.jsonl
+ /tmp/gh-aw/usage/agent_usage.json
/tmp/gh-aw/usage/agent_usage.jsonl
/tmp/gh-aw/usage/detection_usage.jsonl
+ /tmp/gh-aw/usage/evals.jsonl
+ /tmp/gh-aw/usage/github_rate_limits.jsonl
/tmp/gh-aw/usage/agent/token_usage.jsonl
/tmp/gh-aw/usage/detection/token_usage.jsonl
+ /tmp/gh-aw/usage/activity/summary.json
if-no-files-found: ignore
+ - name: Restore daily AIC usage cache
+ id: restore-daily-aic-cache-conclusion
+ if: always()
+ continue-on-error: true
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ key: agentic-workflow-usage-checkrequirements-${{ github.run_id }}
+ restore-keys: agentic-workflow-usage-checkrequirements-
+ path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
+ - name: Write daily AIC usage cache entry
+ id: write-daily-aic-cache
+ if: always()
+ continue-on-error: true
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ github-token: ${{ github.token }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs');
+ await main();
+ - name: Save daily AIC usage cache
+ id: save-daily-aic-cache
+ if: always()
+ continue-on-error: true
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ key: agentic-workflow-usage-checkrequirements-${{ github.run_id }}
+ path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
+ - name: Upload daily AIC usage cache artifact
+ id: upload-daily-aic-cache
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: aic-usage-cache
+ path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
+ if-no-files-found: ignore
+ retention-days: 7
- name: Process no-op messages
id: noop
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -1159,6 +1229,7 @@ jobs:
GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"
GH_AW_ENGINE_ID: "copilot"
GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
+ GH_AW_ENGINE_SECRET_FAILURE_MESSAGE: "**Alternative**: If your organization has a Copilot subscription, you can avoid the need for a personal access token by adding a top-level `permissions` block to your workflow file. This enables Copilot inference through the org using the built-in GitHub Actions token.\n\n```yaml\npermissions:\n copilot-requests: write\n```\n\nSee: https://github.github.com/gh-aw/reference/engines/#github-copilot-default"
GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
@@ -1170,12 +1241,14 @@ jobs:
GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }}
+ GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }}
GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
+ GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }}
GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
- GH_AW_DAILY_EFFECTIVE_WORKFLOW_EXCEEDED: ${{ needs.activation.outputs.daily_effective_workflow_exceeded }}
- GH_AW_DAILY_EFFECTIVE_WORKFLOW_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_effective_workflow_total_effective_tokens }}
- GH_AW_DAILY_EFFECTIVE_WORKFLOW_THRESHOLD: ${{ needs.activation.outputs.daily_effective_workflow_threshold }}
+ GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }}
+ GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }}
+ GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }}
GH_AW_GROUP_REPORTS: "false"
GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
@@ -1193,11 +1266,12 @@ jobs:
needs:
- activation
- agent
- if: >
- always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true')
+ if: always() && needs.agent.result != 'skipped'
runs-on: ubuntu-latest
permissions:
contents: read
+ env:
+ GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
outputs:
aic: ${{ steps.parse_detection_token_usage.outputs.aic }}
detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
@@ -1206,7 +1280,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
+ uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1215,8 +1289,8 @@ jobs:
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.60"
- GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_VERSION: "1.0.75"
+ GH_AW_INFO_AWF_VERSION: "v0.27.42"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Download agent output artifact
id: download-agent-output
@@ -1243,7 +1317,7 @@ jobs:
rm -rf /tmp/gh-aw/sandbox/firewall/logs
rm -rf /tmp/gh-aw/sandbox/firewall/audit
- name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607 ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0
- name: Check if detection needed
id: detection_guard
if: always()
@@ -1262,7 +1336,7 @@ jobs:
if: always() && steps.detection_guard.outputs.run_detection == 'true'
run: |
rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json"
- rm -f /home/runner/.copilot/mcp-config.json
+ rm -f "$HOME/.copilot/mcp-config.json"
rm -f "$GITHUB_WORKSPACE/.gemini/settings.json"
- name: Prepare threat detection files
if: always() && steps.detection_guard.outputs.run_detection == 'true'
@@ -1306,11 +1380,11 @@ jobs:
node-version: '24'
package-manager-cache: false
- name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.75
env:
GH_HOST: github.com
- name: Install AWF binary
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.42
- name: Execute GitHub Copilot CLI
if: always() && steps.detection_guard.outputs.run_detection == 'true'
continue-on-error: true
@@ -1320,46 +1394,51 @@ jobs:
run: |
set -o pipefail
printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'rm -f /home/runner/.copilot/settings.json' EXIT
- mkdir -p /home/runner/.copilot
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > /home/runner/.copilot/settings.json
+ trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
+ mkdir -p "$HOME/.copilot"
+ printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
+ export XDG_CONFIG_HOME="$HOME"
touch /tmp/gh-aw/agent-step-summary.md
GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
export GH_AW_NODE_BIN
export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
(umask 177 && touch /tmp/gh-aw/threat-detection/detection.log)
- GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}"
- printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs"
+ GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-400}"
+ printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.42/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"fable\",\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.42,squid=sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0,agent=sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b,agent-act=sha256:a14ad974484aa518aab83d40f3f141175dfd171d3745e01c092375b970f73a20,api-proxy=sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607,cli-proxy=sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""
+ GH_AW_DOCKER_HOST=""
if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"
+ GH_AW_DOCKER_HOST="${DOCKER_HOST}"
+ fi
+ if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
+ _GH_AW_CHROOT_JSON=$(jq -c --arg src "${RUNNER_TEMP}/gh-aw" --arg user "$(id -un)" --argjson uid "$(id -u)" --argjson gid "$(id -g)" --arg home "${RUNNER_TEMP}/gh-aw/home" '.chroot={"binariesSourcePath":$src,"identity":{"user":$user,"uid":$uid,"gid":$gid,"home":$home}}' "${RUNNER_TEMP}/gh-aw/awf-config.json") || { echo "chroot config patch failed" >&2; exit 1; }
+ printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
+ printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
fi
GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"
+ GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
if [ -d "$GH_AW_TOOL_CACHE" ]; then
if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
fi
- elif [ -d "/home/runner/work/_tool" ]; then
- GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro"
fi
- # shellcheck disable=SC1003
- sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
- -- /bin/bash -c 'set +o histexpand; GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
+ # shellcheck disable=SC1003,SC2016,SC2086
+ awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \
+ -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
env:
AWF_REFLECT_ENABLED: 1
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
+ GH_AW_LLM_PROVIDER: github
+ GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}
GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
GH_AW_PHASE: detection
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
GH_AW_TIMEOUT_MINUTES: 20
- GH_AW_VERSION: v0.79.6
+ GH_AW_VERSION: v0.83.4
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_AW: true
GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
@@ -1373,7 +1452,7 @@ jobs:
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
RUNNER_TEMP: ${{ runner.temp }}
- XDG_CONFIG_HOME: /home/runner
+ TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- name: Parse threat detection token usage for step summary
id: parse_detection_token_usage
if: always()
@@ -1427,40 +1506,6 @@ jobs:
}
}
- pre_activation:
- runs-on: ubuntu-slim
- outputs:
- activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
- matched_command: ''
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.60"
- GH_AW_INFO_AWF_VERSION: "v0.27.2"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Check team membership for workflow
- id: check_membership
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
- await main();
-
prepare:
needs: activation
if: github.event.workflow_run.conclusion == 'success'
@@ -1468,7 +1513,6 @@ jobs:
permissions:
actions: read
contents: read
-
outputs:
pr_number: ${{ steps.prepare.outputs.pr_number }}
skip: ${{ steps.prepare.outputs.skip }}
@@ -1476,8 +1520,7 @@ jobs:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
- # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- run: |
+ run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
GH_HOST="${GITHUB_SERVER_URL#https://}"
@@ -1507,7 +1550,6 @@ jobs:
runs-on: ubuntu-slim
permissions:
contents: read
- discussions: write
issues: write
pull-requests: write
timeout-minutes: 45
@@ -1521,7 +1563,8 @@ jobs:
GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
GH_AW_ENGINE_ID: "copilot"
GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
- GH_AW_ENGINE_VERSION: "1.0.60"
+ GH_AW_ENGINE_VERSION: "1.0.75"
+ GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
GH_AW_WORKFLOW_ID: "check-requirements"
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
@@ -1538,7 +1581,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
+ uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1547,8 +1590,8 @@ jobs:
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.60"
- GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_VERSION: "1.0.75"
+ GH_AW_INFO_AWF_VERSION: "v0.27.42"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Download agent output artifact
id: download-agent-output
@@ -1567,8 +1610,7 @@ jobs:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
- # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- run: |
+ run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
GH_HOST="${GITHUB_SERVER_URL#https://}"
@@ -1589,7 +1631,7 @@ jobs:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs');
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs');
await main();
- name: Upload Safe Outputs Items
if: always()
@@ -1599,5 +1641,6 @@ jobs:
path: |
/tmp/gh-aw/safe-output-items.jsonl
/tmp/gh-aw/temporary-id-map.json
+ /tmp/gh-aw/process-safe-outputs.stdout.log
+ /tmp/gh-aw/process-safe-outputs.stderr.log
if-no-files-found: ignore
-
diff --git a/.github/workflows/check-requirements.md b/.github/workflows/check-requirements.md
index 6d7e788264b0..cd406bde4eb0 100644
--- a/.github/workflows/check-requirements.md
+++ b/.github/workflows/check-requirements.md
@@ -3,6 +3,11 @@ on:
workflow_run:
workflows: ["Check requirements (deterministic)"]
types: [completed]
+ # On workflow_run the actor is the PR author, so the default role gate
+ # (admin/maintainer/write) skips every PR from an outside contributor — which is
+ # exactly who this review is for. The job is read-only and its single safe-output
+ # is a sanitized comment on the PR recorded in the trusted upstream artifact.
+ roles: all
permissions:
contents: read
actions: read
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 7675005226b4..5919d558c6d9 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -44,7 +44,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@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
+ uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
remove-stale-when-updated: true
diff --git a/AGENTS.md b/AGENTS.md
index 504af7dc10eb..882d266537a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,6 +45,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before. Comments in tests that explain why a function call or assertion is made are ok.
- Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading.
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
+- Sensitive service actions, i.e. those that can change configuration or have security implications, should require an admin user. Register them with the `async_register_admin_service` service helper, which checks this for you.
## AI policy
diff --git a/CODEOWNERS b/CODEOWNERS
index 6111b3cfa3e2..6399a23dcb90 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -215,8 +215,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/azure_service_bus/ @hfurubotten
/homeassistant/components/azure_storage/ @zweckj
/tests/components/azure_storage/ @zweckj
-/homeassistant/components/backblaze_b2/ @hugo-vrijswijk @ElCruncharino
-/tests/components/backblaze_b2/ @hugo-vrijswijk @ElCruncharino
+/homeassistant/components/backblaze_b2/ @ElCruncharino
+/tests/components/backblaze_b2/ @ElCruncharino
/homeassistant/components/backup/ @home-assistant/core
/tests/components/backup/ @home-assistant/core
/homeassistant/components/baf/ @bdraco @jfroy
@@ -1139,8 +1139,8 @@ CLAUDE.md @home-assistant/core
/tests/components/metoffice/ @MrHarcombe @avee87
/homeassistant/components/microbees/ @microBeesTech
/tests/components/microbees/ @microBeesTech
-/homeassistant/components/midea/ @chemelli74 @rokam @wuwentao
-/tests/components/midea/ @chemelli74 @rokam @wuwentao
+/homeassistant/components/midea/ @chemelli74 @rokam @wuwentao @caibinqing
+/tests/components/midea/ @chemelli74 @rokam @wuwentao @caibinqing
/homeassistant/components/miele/ @astrandb
/tests/components/miele/ @astrandb
/homeassistant/components/mikrotik/ @engrbm87 @chemelli74
diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py
index e8a3dd8672ed..88a8b1d54152 100644
--- a/homeassistant/auth/__init__.py
+++ b/homeassistant/auth/__init__.py
@@ -152,10 +152,6 @@ class AuthManagerFlowManager(
cast(Mapping[str, str], result["data"]),
)
- if flow.context.get("credential_only"):
- result["result"] = credentials
- return result
-
# multi-factor module cannot enabled for new credential
# which has not linked to a user yet
if auth_provider.support_mfa and not credentials.is_new:
diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py
index 5646d23d461f..a41b03d1074d 100644
--- a/homeassistant/auth/models.py
+++ b/homeassistant/auth/models.py
@@ -26,7 +26,6 @@ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token"
class AuthFlowContext(FlowContext, total=False):
"""Typed context dict for auth flow."""
- credential_only: bool
ip_address: IPv4Address | IPv6Address
redirect_uri: str
diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py
index 1dcf47561229..4ff28af4d057 100644
--- a/homeassistant/components/alexa_devices/coordinator.py
+++ b/homeassistant/components/alexa_devices/coordinator.py
@@ -240,10 +240,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
(DOMAIN, serial_num), self.config_entry.entry_id
)
if device:
- 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)
async def _async_remove_routine_stale(
self,
diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json
index a4119a03abc3..3ca21d35d03c 100644
--- a/homeassistant/components/anova/manifest.json
+++ b/homeassistant/components/anova/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["anova_wifi"],
- "requirements": ["anova-wifi==0.17.1"]
+ "requirements": ["anova-wifi==1.0.1"]
}
diff --git a/homeassistant/components/anthemav/__init__.py b/homeassistant/components/anthemav/__init__.py
index 2185d0ddf4a9..8d14dfbf3ac0 100644
--- a/homeassistant/components/anthemav/__init__.py
+++ b/homeassistant/components/anthemav/__init__.py
@@ -6,12 +6,21 @@ import anthemav
from anthemav.device_error import DeviceError
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_MAC,
+ CONF_MODEL,
+ CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP,
+ Platform,
+)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS
+from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS, DOMAIN, MANUFACTURER
type AnthemavConfigEntry = ConfigEntry[anthemav.Connection]
@@ -43,6 +52,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthemavConfigEntry) ->
entry.runtime_data = avr
+ # Register the zone 1 receiver first so higher zones can link to it as a
+ # via device when their entities are created.
+ mac_address = entry.data[CONF_MAC]
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, mac_address)},
+ connections={(CONNECTION_NETWORK_MAC, mac_address)},
+ name=entry.title,
+ manufacturer=MANUFACTURER,
+ model=entry.data[CONF_MODEL],
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py
index 60d1a544f262..dd92592d80c9 100644
--- a/homeassistant/components/anthemav/media_player.py
+++ b/homeassistant/components/anthemav/media_player.py
@@ -13,6 +13,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_MAC, CONF_MODEL
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -40,7 +41,13 @@ async def async_setup_entry(
async_add_entities(
AnthemAVR(
- avr.protocol, name, mac_address, model, zone_number, config_entry.entry_id
+ hass,
+ avr.protocol,
+ name,
+ mac_address,
+ model,
+ zone_number,
+ config_entry.entry_id,
)
for zone_number in avr.protocol.zones
)
@@ -64,6 +71,7 @@ class AnthemAVR(MediaPlayerEntity):
def __init__(
self,
+ hass: HomeAssistant,
avr: AVR,
name: str,
mac_address: str,
@@ -85,7 +93,11 @@ class AnthemAVR(MediaPlayerEntity):
name=f"Zone {zone_number}",
manufacturer=MANUFACTURER,
model=model,
- via_device=(DOMAIN, mac_address),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, mac_address),
+ config_entry_id=entry_id,
+ ),
)
else:
# Zone 1 is the physical receiver that owns the network MAC; higher
diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py
index d1af79a5c647..c347ac83c5ab 100644
--- a/homeassistant/components/aqvify/coordinator.py
+++ b/homeassistant/components/aqvify/coordinator.py
@@ -131,10 +131,7 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
self.config_entry.entry_id,
)
if device:
- 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)
self.previous_devices = current_devices
device_data = {}
diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py
index f389bc55a2b9..90d58791a57a 100644
--- a/homeassistant/components/arcam_fmj/__init__.py
+++ b/homeassistant/components/arcam_fmj/__init__.py
@@ -10,6 +10,7 @@ from arcam.fmj.client import Client
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from .const import DEFAULT_SCAN_INTERVAL
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRuntimeData
@@ -29,6 +30,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) ->
coordinator = ArcamFmjCoordinator(hass, entry, client, zone)
coordinators[zone] = coordinator
+ # Register the zone 1 device before forwarding platforms so the other zones'
+ # entities can link to it via via_device_id.
+ device_registry = dr.async_get(hass)
+ zone1_device = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **coordinators[1].device_info,
+ )
+ for zone, coordinator in coordinators.items():
+ if zone != 1:
+ coordinator.device_info["via_device_id"] = zone1_device.id
+
entry.runtime_data = ArcamFmjRuntimeData(client, coordinators)
entry.async_create_background_task(
diff --git a/homeassistant/components/arcam_fmj/coordinator.py b/homeassistant/components/arcam_fmj/coordinator.py
index 2c4cf7a0811a..65a6457def4b 100644
--- a/homeassistant/components/arcam_fmj/coordinator.py
+++ b/homeassistant/components/arcam_fmj/coordinator.py
@@ -70,9 +70,6 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
)
self.zone_unique_id = f"{unique_id}-{zone}"
- if zone != 1:
- self.device_info["via_device"] = (DOMAIN, unique_id)
-
@override
async def _async_update_data(self) -> None:
"""Fetch data for manual refresh."""
diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json
index 253036290fd9..bff8c3cc670c 100644
--- a/homeassistant/components/assist_satellite/manifest.json
+++ b/homeassistant/components/assist_satellite/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
- "requirements": ["hassil==3.10.0"]
+ "requirements": ["hassil==3.11.0"]
}
diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py
index bf4cc18ebc68..6594acd9eec4 100644
--- a/homeassistant/components/auth/login_flow.py
+++ b/homeassistant/components/auth/login_flow.py
@@ -22,8 +22,7 @@ Pass in parameter 'client_id' and 'redirect_url' validate by indieauth.
Pass in parameter 'handler' to specify the auth provider to use. Auth providers
are identified by type and id.
-And optional parameter 'type' has to set as 'link_user' if login flow used for
-link credential to exist user. Default 'type' is 'authorize'.
+The default 'type' is 'authorize'.
{
"client_id": "https://hassbian.local:8123/",
@@ -54,9 +53,6 @@ Progress the flow. Most flows will be 1 page, but could optionally add extra
login challenges, like TFA. Once the flow has finished, the returned step will
have type FlowResultType.CREATE_ENTRY and "result" key will contain
an authorization code.
-The authorization code associated with an authorized user by default, it will
-associate with an credential if "type" set to "link_user" in
-"/auth/login_flow"
{
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
@@ -351,7 +347,9 @@ class LoginFlowIndexView(LoginFlowBaseView):
[vol.Any(str, None)], vol.Length(2, 2), vol.Coerce(tuple)
),
vol.Required("redirect_uri"): str,
- vol.Optional("type", default="authorize"): str,
+ vol.Optional(
+ "type", default="authorize"
+ ): str, # not used, kept for backwards compatibility
}
)
)
@@ -371,7 +369,6 @@ class LoginFlowIndexView(LoginFlowBaseView):
handler,
context=AuthFlowContext(
ip_address=ip_address(request.remote), # type: ignore[arg-type]
- credential_only=data.get("type") == "link_user",
redirect_uri=redirect_uri,
),
)
diff --git a/homeassistant/components/backblaze_b2/manifest.json b/homeassistant/components/backblaze_b2/manifest.json
index 13d3521519b8..4fca7a86aefb 100644
--- a/homeassistant/components/backblaze_b2/manifest.json
+++ b/homeassistant/components/backblaze_b2/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "backblaze_b2",
"name": "Backblaze B2",
- "codeowners": ["@hugo-vrijswijk", "@ElCruncharino"],
+ "codeowners": ["@ElCruncharino"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/backblaze_b2",
"integration_type": "service",
diff --git a/homeassistant/components/bluesound/button.py b/homeassistant/components/bluesound/button.py
index a6f54adda848..99ddd9247342 100644
--- a/homeassistant/components/bluesound/button.py
+++ b/homeassistant/components/bluesound/button.py
@@ -9,6 +9,7 @@ from pyblu import Player
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -118,8 +119,13 @@ class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity):
manufacturer=sync_status.brand,
model=sync_status.model_name,
model_id=sync_status.model,
- via_device=(DOMAIN, format_mac(sync_status.mac)),
)
+ # The leader (default-port) device is a separate config entry that may
+ # not be loaded yet; link to it only if it already exists.
+ if leader_devices := dr.async_get(coordinator.hass).async_get_devices(
+ identifiers={(DOMAIN, format_mac(sync_status.mac))}
+ ):
+ self._attr_device_info["via_device_id"] = leader_devices[0].id
@override
async def async_press(self) -> None:
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index ac2dd0033171..baa446706608 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -19,7 +19,11 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-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.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -131,8 +135,13 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
manufacturer=sync_status.brand,
model=sync_status.model_name,
model_id=sync_status.model,
- via_device=(DOMAIN, format_mac(sync_status.mac)),
)
+ # The leader (default-port) device is a separate config entry that may
+ # not be loaded yet; link to it only if it already exists.
+ if leader_devices := dr.async_get(coordinator.hass).async_get_devices(
+ identifiers={(DOMAIN, format_mac(sync_status.mac))}
+ ):
+ self._attr_device_info["via_device_id"] = leader_devices[0].id
@override
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py
index 83c979fe5ec2..519986e473e5 100644
--- a/homeassistant/components/bosch_alarm/alarm_control_panel.py
+++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py
@@ -26,9 +26,11 @@ async def async_setup_entry(
async_add_entities(
AreaAlarmControlPanel(
+ hass,
panel,
area_id,
config_entry.unique_id or config_entry.entry_id,
+ config_entry.entry_id,
)
for area_id in panel.areas
)
@@ -48,9 +50,18 @@ class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
_attr_code_arm_required = False
_attr_name = None
- def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ panel: Panel,
+ area_id: int,
+ unique_id: str,
+ config_entry_id: str,
+ ) -> None:
"""Initialise a Bosch Alarm control panel entity."""
- super().__init__(panel, area_id, unique_id, True, False, True)
+ super().__init__(
+ hass, panel, area_id, unique_id, config_entry_id, True, False, True
+ )
self._attr_unique_id = self._area_unique_id
@property
diff --git a/homeassistant/components/bosch_alarm/binary_sensor.py b/homeassistant/components/bosch_alarm/binary_sensor.py
index 02992f149157..1c87511676ed 100644
--- a/homeassistant/components/bosch_alarm/binary_sensor.py
+++ b/homeassistant/components/bosch_alarm/binary_sensor.py
@@ -116,16 +116,17 @@ async def async_setup_entry(
) -> None:
"""Set up binary sensors for alarm points and the connection status."""
panel = config_entry.runtime_data
+ unique_id = config_entry.unique_id or config_entry.entry_id
entities: list[BinarySensorEntity] = [
- PointSensor(panel, point_id, config_entry.unique_id or config_entry.entry_id)
+ PointSensor(hass, panel, point_id, unique_id, config_entry.entry_id)
for point_id in panel.points
]
entities.extend(
PanelFaultsSensor(
panel,
- config_entry.unique_id or config_entry.entry_id,
+ unique_id,
fault_type,
)
for fault_type in FAULT_TYPES
@@ -133,14 +134,14 @@ async def async_setup_entry(
entities.extend(
AreaReadyToArmSensor(
- panel, area_id, config_entry.unique_id or config_entry.entry_id, "away"
+ hass, panel, area_id, unique_id, config_entry.entry_id, "away"
)
for area_id in panel.areas
)
entities.extend(
AreaReadyToArmSensor(
- panel, area_id, config_entry.unique_id or config_entry.entry_id, "home"
+ hass, panel, area_id, unique_id, config_entry.entry_id, "home"
)
for area_id in panel.areas
)
@@ -182,10 +183,18 @@ class AreaReadyToArmSensor(BoschAlarmAreaEntity, BinarySensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
- self, panel: Panel, area_id: int, unique_id: str, arm_type: str
+ self,
+ hass: HomeAssistant,
+ panel: Panel,
+ area_id: int,
+ unique_id: str,
+ config_entry_id: str,
+ arm_type: str,
) -> None:
"""Set up a binary sensor for arming status in a bosch panel."""
- super().__init__(panel, area_id, unique_id, False, False, True)
+ super().__init__(
+ hass, panel, area_id, unique_id, config_entry_id, False, False, True
+ )
self.panel = panel
self._arm_type = arm_type
self._attr_translation_key = f"area_ready_to_arm_{arm_type}"
@@ -207,9 +216,16 @@ class PointSensor(BoschAlarmPointEntity, BinarySensorEntity):
_attr_name = None
- def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ panel: Panel,
+ point_id: int,
+ unique_id: str,
+ config_entry_id: str,
+ ) -> None:
"""Set up a binary sensor entity for a point in a bosch alarm panel."""
- super().__init__(panel, point_id, unique_id)
+ super().__init__(hass, panel, point_id, unique_id, config_entry_id)
self._attr_unique_id = self._point_unique_id
@property
diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py
index 33a6516792a6..c52e1e990b9d 100644
--- a/homeassistant/components/bosch_alarm/entity.py
+++ b/homeassistant/components/bosch_alarm/entity.py
@@ -5,6 +5,8 @@ from typing import override
from bosch_alarm_mode2 import Panel
from homeassistant.components.sensor import Entity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN
@@ -56,9 +58,11 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
def __init__(
self,
+ hass: HomeAssistant,
panel: Panel,
area_id: int,
unique_id: str,
+ config_entry_id: str,
observe_alarms: bool,
observe_ready: bool,
observe_status: bool,
@@ -75,7 +79,9 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
identifiers={(DOMAIN, self._area_unique_id)},
name=self._area.name,
manufacturer="Bosch Security Systems",
- via_device=(DOMAIN, unique_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, unique_id), config_entry_id=config_entry_id
+ ),
)
@override
@@ -104,7 +110,14 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
class BoschAlarmPointEntity(BoschAlarmEntity):
"""A base entity for point related entities within a bosch alarm panel."""
- def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ panel: Panel,
+ point_id: int,
+ unique_id: str,
+ config_entry_id: str,
+ ) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._point_id = point_id
@@ -114,7 +127,9 @@ class BoschAlarmPointEntity(BoschAlarmEntity):
identifiers={(DOMAIN, self._point_unique_id)},
name=self._point.name,
manufacturer="Bosch Security Systems",
- via_device=(DOMAIN, unique_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, unique_id), config_entry_id=config_entry_id
+ ),
)
@override
@@ -133,7 +148,14 @@ class BoschAlarmPointEntity(BoschAlarmEntity):
class BoschAlarmDoorEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
- def __init__(self, panel: Panel, door_id: int, unique_id: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ panel: Panel,
+ door_id: int,
+ unique_id: str,
+ config_entry_id: str,
+ ) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._door_id = door_id
@@ -143,7 +165,9 @@ class BoschAlarmDoorEntity(BoschAlarmEntity):
identifiers={(DOMAIN, self._door_unique_id)},
name=self._door.name,
manufacturer="Bosch Security Systems",
- via_device=(DOMAIN, unique_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, unique_id), config_entry_id=config_entry_id
+ ),
)
@override
@@ -162,7 +186,14 @@ class BoschAlarmDoorEntity(BoschAlarmEntity):
class BoschAlarmOutputEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
- def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ panel: Panel,
+ output_id: int,
+ unique_id: str,
+ config_entry_id: str,
+ ) -> None:
"""Set up a output related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._output_id = output_id
@@ -172,7 +203,9 @@ class BoschAlarmOutputEntity(BoschAlarmEntity):
identifiers={(DOMAIN, self._output_unique_id)},
name=self._output.name,
manufacturer="Bosch Security Systems",
- via_device=(DOMAIN, unique_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, unique_id), config_entry_id=config_entry_id
+ ),
)
@override
diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py
index deb53a07079e..d292f97bde95 100644
--- a/homeassistant/components/bosch_alarm/sensor.py
+++ b/homeassistant/components/bosch_alarm/sensor.py
@@ -82,7 +82,9 @@ async def async_setup_entry(
unique_id = config_entry.unique_id or config_entry.entry_id
async_add_entities(
- BoschAreaSensor(panel, area_id, unique_id, template)
+ BoschAreaSensor(
+ hass, panel, area_id, unique_id, config_entry.entry_id, template
+ )
for area_id in panel.areas
for template in SENSOR_TYPES
)
@@ -98,16 +100,20 @@ class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
panel: Panel,
area_id: int,
unique_id: str,
+ config_entry_id: str,
entity_description: BoschAlarmSensorEntityDescription,
) -> None:
"""Set up an area sensor entity for a bosch alarm panel."""
super().__init__(
+ hass,
panel,
area_id,
unique_id,
+ config_entry_id,
entity_description.observe_alarms,
entity_description.observe_ready,
entity_description.observe_status,
diff --git a/homeassistant/components/bosch_alarm/switch.py b/homeassistant/components/bosch_alarm/switch.py
index d7bce72ef0da..238e077d468f 100644
--- a/homeassistant/components/bosch_alarm/switch.py
+++ b/homeassistant/components/bosch_alarm/switch.py
@@ -59,18 +59,19 @@ async def async_setup_entry(
"""Set up switch entities for outputs."""
panel = config_entry.runtime_data
+ unique_id = config_entry.unique_id or config_entry.entry_id
entities: list[SwitchEntity] = [
- PanelOutputEntity(
- panel, output_id, config_entry.unique_id or config_entry.entry_id
- )
+ PanelOutputEntity(hass, panel, output_id, unique_id, config_entry.entry_id)
for output_id in panel.outputs
]
entities.extend(
PanelDoorEntity(
+ hass,
panel,
door_id,
- config_entry.unique_id or config_entry.entry_id,
+ unique_id,
+ config_entry.entry_id,
entity_description,
)
for door_id in panel.doors
@@ -90,13 +91,15 @@ class PanelDoorEntity(BoschAlarmDoorEntity, SwitchEntity):
def __init__(
self,
+ hass: HomeAssistant,
panel: Panel,
door_id: int,
unique_id: str,
+ config_entry_id: str,
entity_description: BoschAlarmSwitchEntityDescription,
) -> None:
"""Set up a switch entity for a door on a bosch alarm panel."""
- super().__init__(panel, door_id, unique_id)
+ super().__init__(hass, panel, door_id, unique_id, config_entry_id)
self.entity_description = entity_description
self._attr_unique_id = f"{self._door_unique_id}_{entity_description.key}"
@@ -134,9 +137,16 @@ class PanelOutputEntity(BoschAlarmOutputEntity, SwitchEntity):
_attr_name = None
- def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ panel: Panel,
+ output_id: int,
+ unique_id: str,
+ config_entry_id: str,
+ ) -> None:
"""Set up an output entity for a bosch alarm panel."""
- super().__init__(panel, output_id, unique_id)
+ super().__init__(hass, panel, output_id, unique_id, config_entry_id)
self._attr_unique_id = self._output_unique_id
@property
diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py
index f77313556719..7f5dc3dd7052 100644
--- a/homeassistant/components/bosch_shc/binary_sensor.py
+++ b/homeassistant/components/bosch_shc/binary_sensor.py
@@ -37,6 +37,7 @@ async def async_setup_entry(
entities: list[BinarySensorEntity] = [
ShutterContactSensor(
+ hass=hass,
device=binary_sensor,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
@@ -49,6 +50,7 @@ async def async_setup_entry(
entities.extend(
BatterySensor(
+ hass=hass,
device=binary_sensor,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
@@ -75,9 +77,11 @@ class ShutterContactSensor(SHCEntity, BinarySensorEntity):
_attr_name = None
_device: SHCShutterContact
- def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str
+ ) -> None:
"""Initialize an SHC shutter contact sensor."""
- super().__init__(device, parent_id, entry_id)
+ super().__init__(hass, device, parent_id, entry_id)
switcher: dict[str | None, BinarySensorDeviceClass] = {
"ENTRANCE_DOOR": BinarySensorDeviceClass.DOOR,
"REGULAR_WINDOW": BinarySensorDeviceClass.WINDOW,
@@ -101,9 +105,11 @@ class BatterySensor(SHCEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.BATTERY
_device: SHCBatteryDevice
- def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str
+ ) -> None:
"""Initialize an SHC battery reporting sensor."""
- super().__init__(device, parent_id, entry_id)
+ super().__init__(hass, device, parent_id, entry_id)
self._attr_unique_id = f"{device.serial}_battery"
@property
diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py
index 536c36786879..9512db0db494 100644
--- a/homeassistant/components/bosch_shc/cover.py
+++ b/homeassistant/components/bosch_shc/cover.py
@@ -33,6 +33,7 @@ async def async_setup_entry(
async_add_entities(
ShutterControlCover(
+ hass=hass,
device=cover,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py
index b43a07741057..07098cfcb88c 100644
--- a/homeassistant/components/bosch_shc/entity.py
+++ b/homeassistant/components/bosch_shc/entity.py
@@ -2,7 +2,7 @@
from typing import override
-from boschshcpy import SHCDevice, SHCIntrusionSystem
+from boschshcpy import SHCDevice
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -21,7 +21,7 @@ async def async_remove_devices(
(DOMAIN, entity.device_id), entry_id
)
if device is not None:
- dev_registry.async_update_device(device.id, remove_config_entry_id=entry_id)
+ dev_registry.async_remove_device(device.id)
class SHCBaseEntity(Entity):
@@ -30,9 +30,7 @@ class SHCBaseEntity(Entity):
_attr_should_poll = False
_attr_has_entity_name = True
- def __init__(
- self, device: SHCDevice | SHCIntrusionSystem, parent_id: str, entry_id: str
- ) -> None:
+ def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
"""Initialize the generic SHC device."""
self._device = device
self._entry_id = entry_id
@@ -67,16 +65,25 @@ class SHCEntity(SHCBaseEntity):
_device: SHCDevice
- def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str
+ ) -> None:
"""Initialize generic SHC device."""
self._attr_unique_id = device.serial
- self._attr_device_info = DeviceInfo(
+ device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
manufacturer=device.manufacturer,
model=device.device_model,
name=device.name,
- via_device=(DOMAIN, device.root_device_id),
)
+ # boschshcpy may render the hub identifier (shc_info.unique_id) and a
+ # device's root_device_id differently, so the lookup can miss; link only
+ # when it resolves instead of raising out of setup.
+ if hub := dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, device.root_device_id), entry_id
+ ):
+ device_info["via_device_id"] = hub.id
+ self._attr_device_info = device_info
super().__init__(device=device, parent_id=parent_id, entry_id=entry_id)
@override
@@ -102,32 +109,3 @@ class SHCEntity(SHCBaseEntity):
def available(self) -> bool:
"""Return false if status is unavailable."""
return self._device.status == "AVAILABLE"
-
-
-class SHCDomainEntity(SHCBaseEntity):
- """Representation of a SHC domain service entity."""
-
- _device: SHCIntrusionSystem
-
- def __init__(
- self, domain: SHCIntrusionSystem, parent_id: str, entry_id: str
- ) -> None:
- """Initialize the generic SHC device."""
- self._attr_unique_id = domain.id
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, domain.id)},
- manufacturer=domain.manufacturer,
- model=domain.device_model,
- name=domain.name,
- via_device=(
- DOMAIN,
- parent_id,
- ),
- )
- super().__init__(device=domain, parent_id=parent_id, entry_id=entry_id)
-
- @property
- @override
- def available(self) -> bool:
- """Return false if status is unavailable."""
- return self._device.system_availability
diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py
index 56da180b2ebf..114decd4880b 100644
--- a/homeassistant/components/bosch_shc/sensor.py
+++ b/homeassistant/components/bosch_shc/sensor.py
@@ -212,6 +212,7 @@ async def async_setup_entry(
entities: list[SensorEntity] = [
SHCSensor(
+ hass,
device,
description,
shc_info.unique_id,
@@ -226,6 +227,7 @@ async def async_setup_entry(
entities.extend(
SHCSensor(
+ hass,
device,
description,
shc_info.unique_id,
@@ -240,6 +242,7 @@ async def async_setup_entry(
entities.extend(
SHCSensor(
+ hass,
device,
description,
shc_info.unique_id,
@@ -263,6 +266,7 @@ async def async_setup_entry(
]
entities.extend(
SHCSensor(
+ hass,
device,
description,
shc_info.unique_id,
@@ -274,6 +278,7 @@ async def async_setup_entry(
entities.extend(
SHCSensor(
+ hass,
device,
description,
shc_info.unique_id,
@@ -297,13 +302,14 @@ class SHCSensor[_DeviceT: SHCDevice](SHCEntity, SensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
device: _DeviceT,
entity_description: SHCSensorEntityDescription[_DeviceT],
parent_id: str,
entry_id: str,
) -> None:
"""Initialize sensor."""
- super().__init__(device, parent_id, entry_id)
+ super().__init__(hass, device, parent_id, entry_id)
self._device: _DeviceT = device
self.entity_description = entity_description
self._attr_unique_id = f"{device.serial}_{entity_description.key}"
diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py
index ac3f9e110353..091ca8e5a74f 100644
--- a/homeassistant/components/bosch_shc/switch.py
+++ b/homeassistant/components/bosch_shc/switch.py
@@ -89,6 +89,7 @@ async def async_setup_entry(
entities: list[SwitchEntity] = [
SHCSwitch(
+ hass=hass,
device=switch,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
@@ -99,6 +100,7 @@ async def async_setup_entry(
entities.extend(
SHCRoutingSwitch(
+ hass=hass,
device=switch,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
@@ -108,6 +110,7 @@ async def async_setup_entry(
entities.extend(
SHCSwitch(
+ hass=hass,
device=switch,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
@@ -118,6 +121,7 @@ async def async_setup_entry(
entities.extend(
SHCSwitch(
+ hass=hass,
device=switch,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
@@ -128,6 +132,7 @@ async def async_setup_entry(
entities.extend(
SHCSwitch(
+ hass=hass,
device=switch,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
@@ -138,6 +143,7 @@ async def async_setup_entry(
entities.extend(
SHCSwitch(
+ hass=hass,
device=switch,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
@@ -156,13 +162,14 @@ class SHCSwitch(SHCEntity, SwitchEntity):
def __init__(
self,
+ hass: HomeAssistant,
device: SHCDevice,
parent_id: str,
entry_id: str,
description: SHCSwitchEntityDescription,
) -> None:
"""Initialize a SHC switch."""
- super().__init__(device, parent_id, entry_id)
+ super().__init__(hass, device, parent_id, entry_id)
self.entity_description = description
@property
@@ -202,9 +209,11 @@ class SHCRoutingSwitch(SHCEntity, SwitchEntity):
_attr_entity_category = EntityCategory.CONFIG
_device: SHCSmartPlug
- def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str
+ ) -> None:
"""Initialize an SHC routing switch."""
- super().__init__(device, parent_id, entry_id)
+ super().__init__(hass, device, parent_id, entry_id)
self._attr_unique_id = f"{device.serial}_routing"
@property
diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py
index 5784b6d740de..3b4fd6c34bfb 100644
--- a/homeassistant/components/brunt/cover.py
+++ b/homeassistant/components/brunt/cover.py
@@ -38,7 +38,7 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
- BruntDevice(coordinator, serial, thing, entry.entry_id)
+ BruntDevice(coordinator, serial, thing)
for serial, thing in coordinator.data.items()
)
@@ -64,20 +64,17 @@ class BruntDevice(CoordinatorEntity[BruntCoordinator], CoverEntity):
coordinator: BruntCoordinator,
serial: str | None,
thing: Thing,
- entry_id: str,
) -> None:
"""Init the Brunt device."""
super().__init__(coordinator)
self._attr_unique_id = serial
self._thing = thing
- self._entry_id = entry_id
self._remove_update_listener = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)}, # type: ignore[arg-type]
name=self._thing.name,
- via_device=(DOMAIN, self._entry_id),
manufacturer="Brunt",
sw_version=self._thing.fw_version,
model=self._thing.model,
diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py
index 4086be646f1c..a4dbc9957c67 100644
--- a/homeassistant/components/comelit/coordinator.py
+++ b/homeassistant/components/comelit/coordinator.py
@@ -153,10 +153,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
(DOMAIN, identifier), self.config_entry.entry_id
)
if device:
- 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)
class ComelitSerialBridge(ComelitBaseCoordinator[T]):
diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py
index 6a1d13e2bc70..9c59e36a3481 100644
--- a/homeassistant/components/comelit/utils.py
+++ b/homeassistant/components/comelit/utils.py
@@ -68,13 +68,13 @@ async def cleanup_stale_entity(
identifiers.append(f"{config_entry.entry_id}-{device.type}-{device.index}")
if len(identifiers) > 0:
- _async_remove_state_config_entry_from_devices(hass, identifiers, config_entry)
+ _async_remove_stale_devices(hass, identifiers, config_entry)
-def _async_remove_state_config_entry_from_devices(
+def _async_remove_stale_devices(
hass: HomeAssistant, identifiers: list[str], config_entry: ConfigEntry
) -> None:
- """Remove config entry from device."""
+ """Remove stale devices."""
device_registry = dr.async_get(hass)
for identifier in identifiers:
@@ -82,15 +82,8 @@ def _async_remove_state_config_entry_from_devices(
(DOMAIN, identifier), config_entry.entry_id
)
if device:
- LOGGER.info(
- "Removing config entry %s from device %s",
- config_entry.title,
- device.name,
- )
- device_registry.async_update_device(
- device_id=device.id,
- remove_config_entry_id=config_entry.entry_id,
- )
+ LOGGER.info("Removing device %s", device.name)
+ device_registry.async_remove_device(device.id)
def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 60a97ce4d772..27727f691b79 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
- "requirements": ["hassil==3.10.0", "home-assistant-intents==2026.7.30"]
+ "requirements": ["hassil==3.11.0", "home-assistant-intents==2026.7.30"]
}
diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py
index ffa8a1197fc6..c813c5f2a8f9 100644
--- a/homeassistant/components/device_tracker/entity.py
+++ b/homeassistant/components/device_tracker/entity.py
@@ -646,13 +646,13 @@ class ScannerEntity(
@override
def entity_registry_enabled_default(self) -> bool:
"""Return if entity is enabled by default."""
- # If mac_address is None, we can never find a device entry.
+ # If mac_address is None, we can never find a matching device.
return (
- # Do not disable if we won't activate our attach to device logic
+ # Do not disable if we won't activate our own device registration logic
self.mac_address is None
or self.device_info is not None
- # Disable if we automatically attach but there is no device
- or self.find_device_entry() is not None
+ # Disable if the tracked device is not known to any integration
+ or self._async_mac_address_registered()
)
@callback
@@ -681,12 +681,14 @@ class ScannerEntity(
)
@callback
- def find_device_entry(self) -> dr.DeviceEntry | None:
- """Return device entry."""
+ def _async_mac_address_registered(self) -> bool:
+ """Return if a device with the entity's MAC address is registered."""
assert self.mac_address is not None
- return dr.async_get(self.hass).async_get_device(
- connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
+ return bool(
+ dr.async_get(self.hass).async_get_devices(
+ connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
+ )
)
@override
@@ -697,7 +699,7 @@ class ScannerEntity(
not self.registry_entry
or not self.platform.config_entry
or not self.mac_address
- or (device_entry := self.find_device_entry()) is None
+ or not self._async_mac_address_registered()
# Entities should not have a device info. We opt them out
# of this logic if they do.
or self.device_info
@@ -707,27 +709,18 @@ class ScannerEntity(
await super().async_internal_added_to_hass()
return
- 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,
- )
+ # Register our own device now that the tracked device is known to another
+ # integration; matching the split of a pre-migration composite device prunes
+ # the identifiers and connections copied from the composite.
+ device_entry = dr.async_get(self.hass).async_get_or_create(
+ config_entry_id=self.platform.config_entry.entry_id,
+ config_subentry_id=self.registry_entry.config_subentry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)},
+ default_name=self.hostname or self.mac_address,
+ )
- # Attach entry to device
- if (
- device_entry is not None
- and self.registry_entry.device_id != device_entry.id
- ):
+ # Link the entity's registry entry to the 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
)
diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py
index b15a489cef2e..2c157d473e00 100644
--- a/homeassistant/components/duco/sensor.py
+++ b/homeassistant/components/duco/sensor.py
@@ -262,10 +262,7 @@ async def async_setup_entry(
(DOMAIN, f"{mac}_{node_id}"), entry.entry_id
)
if device:
- device_reg.async_update_device(
- device.id,
- remove_config_entry_id=entry.entry_id,
- )
+ device_reg.async_remove_device(device.id)
known_nodes.difference_update(stale_node_ids)
known_box_sensors.difference_update(
{
diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py
index 8a1650a4f7c3..90eeaf559323 100644
--- a/homeassistant/components/enphase_envoy/__init__.py
+++ b/homeassistant/components/enphase_envoy/__init__.py
@@ -57,16 +57,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b
serial_number=envoy.serial_number,
)
+ envoy_data = coordinator.envoy.data
+
# register the ACB aggregate device before the individual batteries reference
# it as via_device, so they nest under it in the device hierarchy
- if (envoy_data := coordinator.envoy.data) and envoy_data.acb_inventory:
+ if envoy_data and envoy_data.acb_inventory:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{envoy.serial_number}_acb")},
manufacturer="Enphase",
model="ACB",
name=f"ACB {envoy.serial_number}",
- via_device=(DOMAIN, envoy.serial_number),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, envoy.serial_number), config_entry_id=entry.entry_id
+ ),
+ )
+
+ # register the Enpower device before the dry contact relays reference it as
+ # via_device, so they nest under it in the device hierarchy
+ if envoy_data and (enpower := envoy_data.enpower):
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, enpower.serial_number)},
+ manufacturer="Enphase",
+ model="Enpower",
+ name=f"Enpower {enpower.serial_number}",
+ sw_version=str(enpower.firmware_version),
+ serial_number=enpower.serial_number,
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, envoy.serial_number), config_entry_id=entry.entry_id
+ ),
)
entry.runtime_data = coordinator
diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py
index 4560a0e83984..ae0de3cc7484 100644
--- a/homeassistant/components/enphase_envoy/binary_sensor.py
+++ b/homeassistant/components/enphase_envoy/binary_sensor.py
@@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -209,7 +210,11 @@ class EnvoyEnchargeBinarySensorEntity(EnvoyBaseBinarySensorEntity):
model="Encharge",
name=f"Encharge {serial_number}",
sw_version=str(encharge_inventory[self._serial_number].firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=serial_number,
)
@@ -243,7 +248,11 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity):
model="Enpower",
name=f"Enpower {enpower.serial_number}",
sw_version=str(enpower.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=enpower.serial_number,
)
@@ -277,7 +286,11 @@ class EnvoyCollarBinarySensorEntity(EnvoyBaseBinarySensorEntity):
model="IQ Meter Collar",
name=f"Collar {collar_data.serial_number}",
sw_version=str(collar_data.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=collar_data.serial_number,
)
@@ -311,7 +324,11 @@ class EnvoyC6CCBinarySensorEntity(EnvoyBaseBinarySensorEntity):
model="C6 COMBINER CONTROLLER",
name=f"C6 Combiner {c6cc_data.serial_number}",
sw_version=str(c6cc_data.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=c6cc_data.serial_number,
)
diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py
index ed5d10c33792..4e5d066df802 100644
--- a/homeassistant/components/enphase_envoy/entity.py
+++ b/homeassistant/components/enphase_envoy/entity.py
@@ -8,6 +8,7 @@ from pyenphase import EnvoyData
from pyenphase.exceptions import EnvoyError
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -85,7 +86,11 @@ class EnvoyACBAggregateEntity(EnvoyBaseEntity):
manufacturer="Enphase",
model="ACB",
name=f"ACB {self.envoy_serial_num}",
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
@@ -117,6 +122,10 @@ class EnvoyACBBatteryEntity(EnvoyBaseEntity):
manufacturer="Enphase",
model="AC Battery",
name=f"AC Battery {serial_number}",
- via_device=(DOMAIN, f"{self.envoy_serial_num}_acb"),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, f"{self.envoy_serial_num}_acb"),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=serial_number,
)
diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py
index 803e850d66a0..b39614eeec60 100644
--- a/homeassistant/components/enphase_envoy/number.py
+++ b/homeassistant/components/enphase_envoy/number.py
@@ -16,6 +16,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -120,7 +121,11 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity):
model="Dry contact relay",
name=self.data.dry_contact_settings[relay_id].load_name,
sw_version=str(enpower.firmware_version),
- via_device=(DOMAIN, serial_number),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, serial_number),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
@property
@@ -164,7 +169,11 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity):
model="Enpower",
name=f"Enpower {self._serial_number}",
sw_version=str(enpower.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=self._serial_number,
)
else:
diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py
index 30bd57f2972f..904c81e8b60f 100644
--- a/homeassistant/components/enphase_envoy/select.py
+++ b/homeassistant/components/enphase_envoy/select.py
@@ -12,6 +12,7 @@ from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -187,7 +188,11 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity):
model="Dry contact relay",
name=self.relay.load_name,
sw_version=str(enpower.firmware_version),
- via_device=(DOMAIN, serial_number),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, serial_number),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
@property
@@ -232,7 +237,11 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity):
model="Enpower",
name=f"Enpower {self._serial_number}",
sw_version=str(enpower.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=self._serial_number,
)
else:
diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py
index 649972dc5ebd..baf61e471153 100644
--- a/homeassistant/components/enphase_envoy/sensor.py
+++ b/homeassistant/components/enphase_envoy/sensor.py
@@ -50,6 +50,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -1347,7 +1348,11 @@ class EnvoyInverterEntity(EnvoySensorBaseEntity):
name=f"Inverter {serial_number}",
manufacturer="Enphase",
model="Inverter",
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=serial_number,
)
@@ -1392,7 +1397,11 @@ class EnvoyEnchargeEntity(EnvoySensorBaseEntity):
model="Encharge",
name=f"Encharge {serial_number}",
sw_version=str(encharge_inventory[self._serial_number].firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=serial_number,
)
@@ -1460,7 +1469,11 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity):
model="Enpower",
name=f"Enpower {enpower_data.serial_number}",
sw_version=str(enpower_data.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=enpower_data.serial_number,
)
@@ -1565,7 +1578,11 @@ class EnvoyCollarEntity(EnvoySensorBaseEntity):
model="IQ Meter Collar",
name=f"Collar {collar_data.serial_number}",
sw_version=str(collar_data.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=collar_data.serial_number,
)
@@ -1599,7 +1616,11 @@ class EnvoyC6CCEntity(EnvoySensorBaseEntity):
model="C6 COMBINER CONTROLLER",
name=f"C6 Combiner {c6cc_data.serial_number}",
sw_version=str(c6cc_data.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=c6cc_data.serial_number,
)
diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py
index adc6c7de04d9..797e32ef33eb 100644
--- a/homeassistant/components/enphase_envoy/switch.py
+++ b/homeassistant/components/enphase_envoy/switch.py
@@ -11,6 +11,7 @@ from pyenphase.models.tariff import EnvoyStorageSettings
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -135,7 +136,11 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity):
model="Enpower",
name=f"Enpower {self._serial_number}",
sw_version=str(enpower.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=self._serial_number,
)
@@ -189,7 +194,11 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity):
model="Dry contact relay",
name=relay.load_name,
sw_version=str(enpower.firmware_version),
- via_device=(DOMAIN, enpower.serial_number),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, enpower.serial_number),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
@property
@@ -239,7 +248,11 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity):
model="Enpower",
name=f"Enpower {self._serial_number}",
sw_version=str(enpower.firmware_version),
- via_device=(DOMAIN, self.envoy_serial_num),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self.envoy_serial_num),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
serial_number=self._serial_number,
)
else:
diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py
index 0bba79975973..b57d98b2d6a9 100644
--- a/homeassistant/components/fibaro/__init__.py
+++ b/homeassistant/components/fibaro/__init__.py
@@ -186,9 +186,13 @@ class FibaroController:
identifiers={(DOMAIN, main_device.fibaro_id)},
manufacturer=manufacturer,
name=main_device.name,
- via_device=(DOMAIN, self.hub_serial),
)
+ def link_main_devices_to_hub(self, via_device_id: str) -> None:
+ """Link all main devices to the hub via device."""
+ for device_info in self._device_infos.values():
+ device_info["via_device_id"] = via_device_id
+
def get_device_info(self, device: DeviceModel) -> DeviceInfo:
"""Get the device info by fibaro device id."""
if device.fibaro_id in self._device_infos:
@@ -321,7 +325,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo
# register the hub device info separately as the hub has sometimes no entities
fibaro_info = controller.read_fibaro_info()
device_registry = dr.async_get(hass)
- device_registry.async_get_or_create(
+ hub_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, controller.hub_serial)},
serial_number=controller.hub_serial,
@@ -332,6 +336,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo
configuration_url=controller.get_frontend_url(),
connections={(dr.CONNECTION_NETWORK_MAC, fibaro_info.mac_address)},
)
+ controller.link_main_devices_to_hub(hub_device.id)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py
index b3163741cdc9..6c5a66e284f4 100644
--- a/homeassistant/components/freebox/__init__.py
+++ b/homeassistant/components/freebox/__init__.py
@@ -8,7 +8,7 @@ from freebox_api.exceptions import HttpRequestError
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.event import async_track_time_interval
from .const import DOMAIN, PLATFORMS
@@ -96,6 +96,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> b
entry.runtime_data = router
+ # Register the router device up front so child devices (home devices, disks)
+ # can deterministically resolve it as their via_device on any platform.
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id, **router.device_info
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def async_close_connection(event: Event) -> None:
diff --git a/homeassistant/components/freebox/entity.py b/homeassistant/components/freebox/entity.py
index 6b2b32a61b41..ddc694014e9e 100644
--- a/homeassistant/components/freebox/entity.py
+++ b/homeassistant/components/freebox/entity.py
@@ -54,7 +54,11 @@ class FreeboxHomeEntity(Entity):
model=self._model,
name=node["label"].strip(),
sw_version=self._firmware,
- via_device=(DOMAIN, router.mac),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ router.hass,
+ (DOMAIN, router.mac),
+ config_entry_id=router.config_entry.entry_id,
+ ),
)
async def async_update_signal(self) -> None:
diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py
index dc88284824fd..dfc1bfa58331 100644
--- a/homeassistant/components/freebox/router.py
+++ b/homeassistant/components/freebox/router.py
@@ -108,6 +108,7 @@ class FreeboxRouter:
) -> None:
"""Initialize a Freebox router."""
self.hass = hass
+ self.config_entry = entry
self._host = entry.data[CONF_HOST]
self._port = entry.data[CONF_PORT]
diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py
index dfaa036a1b09..6f28dbbbb540 100644
--- a/homeassistant/components/freebox/sensor.py
+++ b/homeassistant/components/freebox/sensor.py
@@ -17,6 +17,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -246,9 +247,10 @@ class FreeboxDiskSensor(FreeboxSensor):
model=disk["model"],
name=f"Disk {disk['id']}",
sw_version=disk["firmware"],
- via_device=(
- DOMAIN,
- router.mac,
+ via_device_id=dr.async_get_device_id_by_identifier(
+ router.hass,
+ (DOMAIN, router.mac),
+ config_entry_id=router.config_entry.entry_id,
),
)
diff --git a/homeassistant/components/freshr/coordinator.py b/homeassistant/components/freshr/coordinator.py
index 7be8fbf13944..557e0c4b4e18 100644
--- a/homeassistant/components/freshr/coordinator.py
+++ b/homeassistant/components/freshr/coordinator.py
@@ -88,10 +88,7 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]):
if device := device_registry.async_get_device_by_identifier(
(DOMAIN, device_id), self.config_entry.entry_id
):
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=self.config_entry.entry_id,
- )
+ device_registry.async_remove_device(device.id)
return current
diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py
index 5ed5d6a538d0..71cff99c6adf 100644
--- a/homeassistant/components/fyta/coordinator.py
+++ b/homeassistant/components/fyta/coordinator.py
@@ -101,10 +101,7 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
(DOMAIN, f"{self.config_entry.entry_id}-{plant_id}"),
self.config_entry.entry_id,
):
- 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)
_LOGGER.debug("Device removed from device registry: %s", device.id)
# add new devices
diff --git a/homeassistant/components/gatus/coordinator.py b/homeassistant/components/gatus/coordinator.py
index df4ad9545d52..8308861a5610 100644
--- a/homeassistant/components/gatus/coordinator.py
+++ b/homeassistant/components/gatus/coordinator.py
@@ -70,10 +70,7 @@ class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus]
and identifier[1].removeprefix(f"{self._entry_id}_") in stale_keys
for identifier in device.identifiers
):
- device_registry.async_update_device(
- device.id,
- remove_config_entry_id=self._entry_id,
- )
+ device_registry.async_remove_device(device.id)
self._known_endpoint_keys = current_keys
return {ep.key: ep for ep in raw_endpoints}
diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json
index 073215bfaf68..cd44dca3454d 100644
--- a/homeassistant/components/google_generative_ai_conversation/manifest.json
+++ b/homeassistant/components/google_generative_ai_conversation/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["google-genai==1.59.0"]
+ "requirements": ["google-genai==2.16.0"]
}
diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py
index abf9118c8fa5..9bb94e9ea58f 100644
--- a/homeassistant/components/growatt_server/__init__.py
+++ b/homeassistant/components/growatt_server/__init__.py
@@ -23,7 +23,6 @@ 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
@@ -49,7 +48,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
AUTH_API_TOKEN,
AUTH_PASSWORD,
- CACHED_API_KEY,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_PLANT_ID,
@@ -70,6 +68,11 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+# Temporary handoff of authenticated Classic API sessions from async_migrate_entry
+# to async_setup_entry. Avoids a second login() during the same startup, which
+# would hit Growatt's 5-minute per-endpoint rate limit. Popped after first use.
+_CACHED_APIS: dict[str, growattServer.GrowattApi] = {}
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Growatt Server integration."""
@@ -196,8 +199,7 @@ async def async_migrate_entry(
)
# Cache the logged-in API instance for reuse in async_setup_entry()
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][f"{CACHED_API_KEY}{config_entry.entry_id}"] = api
+ _CACHED_APIS[config_entry.entry_id] = api
_LOGGER.info(
"Migrated config entry to use specific plant_id '%s'",
@@ -347,9 +349,7 @@ async def async_setup_entry(
# Check if migration cached an authenticated API instance for us to reuse.
# This avoids calling login() twice (once in migration, once here) which
# would trigger rate limiting.
- cached_api = hass.data.get(DOMAIN, {}).pop(
- f"{CACHED_API_KEY}{config_entry.entry_id}", None
- )
+ cached_api = _CACHED_APIS.pop(config_entry.entry_id, None)
if cached_api:
# Reuse the logged-in API instance from migration (rate limit optimization)
diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py
index 02c40a17aaef..a3478c418ed4 100644
--- a/homeassistant/components/growatt_server/const.py
+++ b/homeassistant/components/growatt_server/const.py
@@ -55,11 +55,6 @@ BATT_MODE_LOAD_FIRST = 0
BATT_MODE_BATTERY_FIRST = 1
BATT_MODE_GRID_FIRST = 2
-# Internal key prefix for caching authenticated API instance
-# Used to pass logged-in session from async_migrate_entry to async_setup_entry
-# to avoid double login() calls that trigger API rate limiting
-CACHED_API_KEY = "_cached_api_"
-
# Supported device types for coordinator creation
SUPPORTED_DEVICE_TYPES = ["inverter", "tlx", "storage", "mix", "min", "sph"]
diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py
index afc47be08a3d..300308eaa71d 100644
--- a/homeassistant/components/guardian/__init__.py
+++ b/homeassistant/components/guardian/__init__.py
@@ -115,6 +115,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuardianConfigEntry) ->
paired_sensor_manager=paired_sensor_manager,
)
+ # Register the valve controller device up front so paired sensors can resolve
+ # it as their via device when they are added:
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, entry.data[CONF_UID])},
+ manufacturer="Elexa",
+ name=f"Guardian valve controller {entry.data[CONF_UID]}",
+ sw_version=valve_controller_coordinators[API_SYSTEM_DIAGNOSTICS].data[
+ "firmware"
+ ],
+ )
+
# Set up all of the Guardian entity platforms:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py
index 6f4c4f964e49..e8ce957a706c 100644
--- a/homeassistant/components/guardian/entity.py
+++ b/homeassistant/components/guardian/entity.py
@@ -2,6 +2,7 @@
from dataclasses import dataclass
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -43,7 +44,11 @@ class PairedSensorEntity(GuardianEntity):
manufacturer="Elexa",
model=coordinator.data["codename"],
name=f"Guardian paired sensor {paired_sensor_uid}",
- via_device=(DOMAIN, entry.data[CONF_UID]),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, entry.data[CONF_UID]),
+ config_entry_id=entry.entry_id,
+ ),
)
self._attr_unique_id = f"{paired_sensor_uid}_{description.key}"
diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py
index 4b4db64374c5..aa5fb872c875 100644
--- a/homeassistant/components/habitica/__init__.py
+++ b/homeassistant/components/habitica/__init__.py
@@ -1,8 +1,10 @@
"""The habitica integration."""
+from typing import TYPE_CHECKING
from uuid import UUID
from habiticalib import Habitica
+from yarl import URL
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
@@ -16,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
-from .const import CONF_API_USER, DOMAIN, X_CLIENT
+from .const import CONF_API_USER, DOMAIN, MANUFACTURER, NAME, X_CLIENT
from .coordinator import (
HabiticaConfigEntry,
HabiticaDataUpdateCoordinator,
@@ -71,6 +73,23 @@ async def async_setup_entry(
config_entry.runtime_data = coordinator
+ if TYPE_CHECKING:
+ assert config_entry.unique_id
+
+ # Register the user device so children (party device) can resolve it
+ # deterministically as their via_device parent before platforms are set up.
+ device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ entry_type=dr.DeviceEntryType.SERVICE,
+ manufacturer=MANUFACTURER,
+ model=NAME,
+ name=coordinator.data.user.profile.name,
+ configuration_url=(
+ URL(config_entry.data[CONF_URL]) / "profile" / config_entry.unique_id
+ ),
+ identifiers={(DOMAIN, config_entry.unique_id)},
+ )
+
party = coordinator.data.user.party.id
hass.data.setdefault(HABITICA_KEY, {})
@@ -81,6 +100,23 @@ async def async_setup_entry(
hass.data[HABITICA_KEY][party] = party_coordinator
party_added_by_this_entry = party
+ if party is not None:
+ # Register the party device so party member devices can resolve it
+ # deterministically as their via_device parent.
+ device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ entry_type=dr.DeviceEntryType.SERVICE,
+ manufacturer=MANUFACTURER,
+ model=NAME,
+ name=hass.data[HABITICA_KEY][party].data.party.summary,
+ identifiers={(DOMAIN, f"{config_entry.unique_id}_{party!s}")},
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, config_entry.unique_id),
+ config_entry_id=config_entry.entry_id,
+ ),
+ )
+
@callback
def _party_update_listener() -> None:
"""On party change, unload coordinator, remove device and reload."""
diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py
index 7857c7ed6081..c031f7d4de34 100644
--- a/homeassistant/components/habitica/entity.py
+++ b/homeassistant/components/habitica/entity.py
@@ -8,6 +8,7 @@ from yarl import URL
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_URL
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -57,14 +58,14 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]):
)
if subentry:
- self._attr_device_info.update(
- DeviceInfo(
- via_device=(
- (
- DOMAIN,
- f"{coordinator.config_entry.unique_id}_{self.user.party.id}",
- )
- )
+ self._attr_device_info["via_device_id"] = (
+ dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (
+ DOMAIN,
+ f"{coordinator.config_entry.unique_id}_{self.user.party.id}",
+ ),
+ config_entry_id=coordinator.config_entry.entry_id,
)
)
@@ -138,6 +139,10 @@ class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]):
model=NAME,
name=coordinator.data.party.summary,
identifiers={(DOMAIN, unique_id)},
- via_device=(DOMAIN, config_entry.unique_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, config_entry.unique_id),
+ config_entry_id=config_entry.entry_id,
+ ),
)
self.content = content
diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py
index a839b95e1ad4..1d73b93c89e5 100644
--- a/homeassistant/components/here_travel_time/coordinator.py
+++ b/homeassistant/components/here_travel_time/coordinator.py
@@ -388,7 +388,8 @@ def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None:
def next_datetime(simple_time: time) -> datetime:
"""Take a time like 08:00:00 and combine it with the current date."""
- combined = datetime.combine(dt_util.start_of_local_day(), simple_time)
- if combined < datetime.now(): # pylint: disable=home-assistant-enforce-naive-now
+ start_of_day = dt_util.start_of_local_day()
+ combined = datetime.combine(start_of_day, simple_time, start_of_day.tzinfo)
+ if combined < dt_util.now():
combined = combined + timedelta(days=1)
return combined
diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py
index 2d0404f0def3..3342cc91744c 100644
--- a/homeassistant/components/hikvision/binary_sensor.py
+++ b/homeassistant/components/hikvision/binary_sensor.py
@@ -267,6 +267,7 @@ async def async_setup_entry(
seen_channels.add(channel)
entities.append(
HikvisionBinarySensor(
+ hass=hass,
entry=entry,
description=BINARY_SENSOR_DESCRIPTIONS[sensor_type],
sensor_type=sensor_type,
@@ -284,13 +285,14 @@ class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
entry: HikvisionConfigEntry,
description: BinarySensorEntityDescription,
sensor_type: str,
channel: int,
) -> None:
"""Initialize the binary sensor."""
- super().__init__(entry, channel)
+ super().__init__(hass, entry, channel)
self.entity_description = description
self._sensor_type = sensor_type
diff --git a/homeassistant/components/hikvision/camera.py b/homeassistant/components/hikvision/camera.py
index fc979f4f93ad..b44a128f8771 100644
--- a/homeassistant/components/hikvision/camera.py
+++ b/homeassistant/components/hikvision/camera.py
@@ -27,7 +27,7 @@ async def async_setup_entry(
if data.channels:
# NVR with video channels from get_video_channels()
async_add_entities(
- HikvisionCamera(entry, channel)
+ HikvisionCamera(hass, entry, channel)
for channel in data.channels.values()
if channel.enabled
)
@@ -36,6 +36,7 @@ async def async_setup_entry(
async_add_entities(
[
HikvisionCamera(
+ hass,
entry,
VideoChannel(id=1, name=data.device_name, enabled=True),
)
@@ -51,11 +52,12 @@ class HikvisionCamera(HikvisionEntity, Camera):
def __init__(
self,
+ hass: HomeAssistant,
entry: HikvisionConfigEntry,
channel: VideoChannel,
) -> None:
"""Initialize the camera."""
- super().__init__(entry, channel.id)
+ super().__init__(hass, entry, channel.id)
self._video_channel = channel
# Build unique ID (unique per platform per integration)
diff --git a/homeassistant/components/hikvision/entity.py b/homeassistant/components/hikvision/entity.py
index 4cd1ed6315f7..0723f685439c 100644
--- a/homeassistant/components/hikvision/entity.py
+++ b/homeassistant/components/hikvision/entity.py
@@ -1,5 +1,7 @@
"""Base entity for Hikvision integration."""
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -14,6 +16,7 @@ class HikvisionEntity(Entity):
def __init__(
self,
+ hass: HomeAssistant,
entry: HikvisionConfigEntry,
channel: int,
) -> None:
@@ -23,12 +26,13 @@ class HikvisionEntity(Entity):
self._camera = self._data.camera
self._channel = channel
- # Device info for device registry
if self._data.device_type == "NVR":
- # NVR channels get their own device linked to the NVR via via_device
+ # NVR channels get their own device linked to the NVR via via_device_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
- via_device=(DOMAIN, self._data.device_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, self._data.device_id), config_entry_id=entry.entry_id
+ ),
translation_key="nvr_channel",
translation_placeholders={
"device_name": self._data.device_name,
diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py
index cdf6c253916c..fc931bebff05 100644
--- a/homeassistant/components/hive/binary_sensor.py
+++ b/homeassistant/components/hive/binary_sensor.py
@@ -79,7 +79,7 @@ async def async_setup_entry(
devices = hive.session.deviceList.get("binary_sensor")
sensors.extend(
- HiveBinarySensorEntity(hive, dev, description)
+ HiveBinarySensorEntity(hass, entry, hive, dev, description)
for dev in devices
for description in BINARY_SENSOR_TYPES
if dev["hiveType"] == description.key
@@ -87,7 +87,7 @@ async def async_setup_entry(
devices = hive.session.deviceList.get("sensor")
sensors.extend(
- HiveSensorEntity(hive, dev, description)
+ HiveSensorEntity(hass, entry, hive, dev, description)
for dev in devices
for description in SENSOR_TYPES
if dev["hiveType"] == description.key
@@ -101,12 +101,14 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
+ entry: HiveConfigEntry,
hive: Hive,
hive_device: dict[str, Any],
entity_description: BinarySensorEntityDescription,
) -> None:
"""Initialise hive binary sensor."""
- super().__init__(hive, hive_device)
+ super().__init__(hass, entry, hive, hive_device)
self.entity_description = entity_description
async def async_update(self) -> None:
@@ -131,12 +133,14 @@ class HiveSensorEntity(HiveEntity, BinarySensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
+ entry: HiveConfigEntry,
hive: Hive,
hive_device: dict[str, Any],
entity_description: BinarySensorEntityDescription,
) -> None:
"""Initialise hive sensor."""
- super().__init__(hive, hive_device)
+ super().__init__(hass, entry, hive, hive_device)
self.entity_description = entity_description
async def async_update(self) -> None:
diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py
index 3e636d825147..e057c6e84cd2 100644
--- a/homeassistant/components/hive/climate.py
+++ b/homeassistant/components/hive/climate.py
@@ -61,7 +61,9 @@ async def async_setup_entry(
hive = entry.runtime_data
devices = hive.session.deviceList.get("climate")
if devices:
- async_add_entities((HiveClimateEntity(hive, dev) for dev in devices), True)
+ async_add_entities(
+ (HiveClimateEntity(hass, entry, hive, dev) for dev in devices), True
+ )
platform = entity_platform.async_get_current_platform()
@@ -97,9 +99,15 @@ class HiveClimateEntity(HiveEntity, ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
- def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: HiveConfigEntry,
+ hive: Hive,
+ hive_device: dict[str, Any],
+ ) -> None:
"""Initialize the Climate device."""
- super().__init__(hive, hive_device)
+ super().__init__(hass, entry, hive, hive_device)
self.thermostat_node_id = hive_device["device_id"]
self._attr_temperature_unit = TEMP_UNIT[hive_device["temperatureunit"]]
diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py
index 474ddac73ece..1b0c85cc4332 100644
--- a/homeassistant/components/hive/entity.py
+++ b/homeassistant/components/hive/entity.py
@@ -1,20 +1,31 @@
"""Support for the Hive devices and services."""
-from typing import Any, override
+from typing import TYPE_CHECKING, Any, override
from apyhiveapi import Hive
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
+if TYPE_CHECKING:
+ from . import HiveConfigEntry
+
class HiveEntity(Entity):
"""Initiate Hive Base Class."""
- def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: HiveConfigEntry,
+ hive: Hive,
+ hive_device: dict[str, Any],
+ ) -> None:
"""Initialize the instance."""
self.hive = hive
self.device = hive_device
@@ -26,7 +37,11 @@ class HiveEntity(Entity):
manufacturer=self.device["deviceData"]["manufacturer"],
name=self.device["device_name"],
sw_version=self.device["deviceData"]["version"],
- via_device=(DOMAIN, self.device["parentDevice"]),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, self.device["parentDevice"]),
+ config_entry_id=entry.entry_id,
+ ),
)
self.attributes: dict[str, Any] = {}
diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py
index 48c82638fdd0..773b5f50a5e5 100644
--- a/homeassistant/components/hive/light.py
+++ b/homeassistant/components/hive/light.py
@@ -35,7 +35,9 @@ async def async_setup_entry(
devices = hive.session.deviceList.get("light")
if not devices:
return
- async_add_entities((HiveDeviceLight(hive, dev) for dev in devices), True)
+ async_add_entities(
+ (HiveDeviceLight(hass, entry, hive, dev) for dev in devices), True
+ )
class HiveDeviceLight(HiveEntity, LightEntity):
@@ -44,9 +46,15 @@ class HiveDeviceLight(HiveEntity, LightEntity):
_attr_min_color_temp_kelvin = 2700 # 370 Mireds
_attr_max_color_temp_kelvin = 6500 # 153 Mireds
- def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: HiveConfigEntry,
+ hive: Hive,
+ hive_device: dict[str, Any],
+ ) -> None:
"""Initialise hive light."""
- super().__init__(hive, hive_device)
+ super().__init__(hass, entry, hive, hive_device)
if self.device["hiveType"] == "warmwhitelight":
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._attr_color_mode = ColorMode.BRIGHTNESS
diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py
index 70a21038d67b..6e13046abb7c 100644
--- a/homeassistant/components/hive/sensor.py
+++ b/homeassistant/components/hive/sensor.py
@@ -99,7 +99,7 @@ async def async_setup_entry(
return
async_add_entities(
(
- HiveSensorEntity(hive, dev, description)
+ HiveSensorEntity(hass, entry, hive, dev, description)
for dev in devices
for description in SENSOR_TYPES
if dev["hiveType"] == description.key
@@ -115,12 +115,14 @@ class HiveSensorEntity(HiveEntity, SensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
+ entry: HiveConfigEntry,
hive: Hive,
hive_device: dict[str, Any],
entity_description: HiveSensorEntityDescription,
) -> None:
"""Initialise hive sensor."""
- super().__init__(hive, hive_device)
+ super().__init__(hass, entry, hive, hive_device)
self.entity_description = entity_description
async def async_update(self) -> None:
diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py
index 22fb848dcf9d..50db7a22dd9d 100644
--- a/homeassistant/components/hive/switch.py
+++ b/homeassistant/components/hive/switch.py
@@ -41,7 +41,7 @@ async def async_setup_entry(
return
async_add_entities(
(
- HiveSwitch(hive, dev, description)
+ HiveSwitch(hass, entry, hive, dev, description)
for dev in devices
for description in SWITCH_TYPES
if dev["hiveType"] == description.key
@@ -55,12 +55,14 @@ class HiveSwitch(HiveEntity, SwitchEntity):
def __init__(
self,
+ hass: HomeAssistant,
+ entry: HiveConfigEntry,
hive: Hive,
hive_device: dict[str, Any],
entity_description: SwitchEntityDescription,
) -> None:
"""Initialise hive switch."""
- super().__init__(hive, hive_device)
+ super().__init__(hass, entry, hive, hive_device)
self.entity_description = entity_description
@refresh_system
diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py
index 9b4dbfbd36b5..1f244bac305f 100644
--- a/homeassistant/components/hive/water_heater.py
+++ b/homeassistant/components/hive/water_heater.py
@@ -52,7 +52,9 @@ async def async_setup_entry(
hive = entry.runtime_data
devices = hive.session.deviceList.get("water_heater")
if devices:
- async_add_entities((HiveWaterHeater(hive, dev) for dev in devices), True)
+ async_add_entities(
+ (HiveWaterHeater(hass, entry, hive, dev) for dev in devices), True
+ )
platform = entity_platform.async_get_current_platform()
diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py
index dc8025c51369..897416b156b6 100644
--- a/homeassistant/components/home_connect/config_flow.py
+++ b/homeassistant/components/home_connect/config_flow.py
@@ -68,14 +68,14 @@ class OAuth2FlowHandler(
) -> ConfigFlowResult:
"""Handle a DHCP discovery."""
device_registry = dr.async_get(self.hass)
- if device_entry := device_registry.async_get_device(
+ for device in device_registry.async_get_devices(
identifiers={
(DOMAIN, discovery_info.hostname),
(DOMAIN, discovery_info.hostname.split("-")[-1]),
}
):
device_registry.async_update_device(
- device_entry.id,
+ device.id,
new_connections={
(dr.CONNECTION_NETWORK_MAC, discovery_info.macaddress)
},
diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py
index 8028fdd14edc..7e85e2957db7 100644
--- a/homeassistant/components/home_connect/coordinator.py
+++ b/homeassistant/components/home_connect/coordinator.py
@@ -368,10 +368,7 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance
(DOMAIN, self.data.info.ha_id), self._config_entry.entry_id
)
if device:
- self.device_registry.async_update_device(
- device_id=device.id,
- remove_config_entry_id=self._config_entry.entry_id,
- )
+ self.device_registry.async_remove_device(device.id)
for (
listener,
context,
diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py
index 9c7ec87def93..0b8b8f2a707c 100644
--- a/homeassistant/components/homee/__init__.py
+++ b/homeassistant/components/homee/__init__.py
@@ -117,10 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
)
if device:
_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)
homee.add_nodes_listener(_remove_node_callback)
diff --git a/homeassistant/components/homee/alarm_control_panel.py b/homeassistant/components/homee/alarm_control_panel.py
index 348d5d00c120..69d5690e7895 100644
--- a/homeassistant/components/homee/alarm_control_panel.py
+++ b/homeassistant/components/homee/alarm_control_panel.py
@@ -61,13 +61,16 @@ def get_supported_features(
async def add_alarm_control_panel_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee alarm control panel entities."""
async_add_entities(
- HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type])
+ HomeeAlarmPanel(
+ hass, attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type]
+ )
for node in nodes
for attribute in node.attributes
if attribute.type in ALARM_DESCRIPTIONS and attribute.editable
@@ -82,7 +85,7 @@ async def async_setup_entry(
"""Add the homee platform for the alarm control panel component."""
await setup_homee_platform(
- add_alarm_control_panel_entities, async_add_entities, config_entry
+ hass, add_alarm_control_panel_entities, async_add_entities, config_entry
)
@@ -93,12 +96,13 @@ class HomeeAlarmPanel(HomeeEntity, AlarmControlPanelEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: HomeeAlarmControlPanelEntityDescription,
) -> None:
"""Initialize a Homee alarm control panel entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
self._attr_code_arm_required = description.code_arm_required
self._attr_supported_features = get_supported_features(description.state_list)
diff --git a/homeassistant/components/homee/binary_sensor.py b/homeassistant/components/homee/binary_sensor.py
index 869a4ca690d8..1d0c7b85efa7 100644
--- a/homeassistant/components/homee/binary_sensor.py
+++ b/homeassistant/components/homee/binary_sensor.py
@@ -156,6 +156,7 @@ BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] =
async def add_binary_sensor_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
@@ -163,7 +164,7 @@ async def add_binary_sensor_entities(
"""Add homee binary sensor entities."""
async_add_entities(
HomeeBinarySensor(
- attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type]
+ hass, attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type]
)
for node in nodes
for attribute in node.attributes
@@ -179,7 +180,7 @@ async def async_setup_entry(
"""Add the homee platform for the binary sensor component."""
await setup_homee_platform(
- add_binary_sensor_entities, async_add_entities, config_entry
+ hass, add_binary_sensor_entities, async_add_entities, config_entry
)
@@ -188,12 +189,13 @@ class HomeeBinarySensor(HomeeEntity, BinarySensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: BinarySensorEntityDescription,
) -> None:
"""Initialize a Homee binary sensor entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
self._attr_translation_key = description.key
diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py
index 610d27987095..f660fe02619e 100644
--- a/homeassistant/components/homee/button.py
+++ b/homeassistant/components/homee/button.py
@@ -43,13 +43,14 @@ BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = {
async def add_button_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee button entities."""
async_add_entities(
- HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
+ HomeeButton(hass, attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable
@@ -63,7 +64,9 @@ async def async_setup_entry(
) -> None:
"""Add the homee platform for the button component."""
- await setup_homee_platform(add_button_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_button_entities, async_add_entities, config_entry
+ )
class HomeeButton(HomeeEntity, ButtonEntity):
@@ -71,12 +74,13 @@ class HomeeButton(HomeeEntity, ButtonEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: ButtonEntityDescription,
) -> None:
"""Initialize a Homee button entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
if attribute.instance == 0:
if attribute.type == AttributeType.IMPULSE:
diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py
index 104bd5034a1b..ed03b72ad81b 100644
--- a/homeassistant/components/homee/climate.py
+++ b/homeassistant/components/homee/climate.py
@@ -33,13 +33,14 @@ ROOM_THERMOSTATS = {
async def add_climate_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee climate entities."""
async_add_entities(
- HomeeClimate(node, config_entry)
+ HomeeClimate(hass, node, config_entry)
for node in nodes
if node.profile in CLIMATE_PROFILES
)
@@ -52,7 +53,9 @@ async def async_setup_entry(
) -> None:
"""Add the Homee platform for the climate component."""
- await setup_homee_platform(add_climate_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_climate_entities, async_add_entities, config_entry
+ )
class HomeeClimate(HomeeNodeEntity, ClimateEntity):
@@ -61,9 +64,11 @@ class HomeeClimate(HomeeNodeEntity, ClimateEntity):
_attr_name = None
_attr_translation_key = DOMAIN
- def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry
+ ) -> None:
"""Initialize a Homee climate entity."""
- super().__init__(node, entry)
+ super().__init__(hass, node, entry)
(
self._attr_supported_features,
diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py
index b2f853dc792f..6cf2d879c2c0 100644
--- a/homeassistant/components/homee/cover.py
+++ b/homeassistant/components/homee/cover.py
@@ -101,6 +101,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
async def add_cover_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
@@ -122,7 +123,7 @@ async def add_cover_entities(
node.name,
)
- async_add_entities(HomeeCover(cover, config_entry) for cover in entities)
+ async_add_entities(HomeeCover(hass, cover, config_entry) for cover in entities)
async def async_setup_entry(
@@ -132,7 +133,9 @@ async def async_setup_entry(
) -> None:
"""Add the homee platform for the cover integration."""
- await setup_homee_platform(add_cover_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_cover_entities, async_add_entities, config_entry
+ )
def is_cover_node(node: HomeeNode) -> bool:
@@ -151,9 +154,11 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
_attr_name = None
- def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry
+ ) -> None:
"""Initialize a homee cover entity."""
- super().__init__(node, entry)
+ super().__init__(hass, node, entry)
self._open_close_attribute = get_open_close_attribute(node)
self._attr_supported_features = get_cover_features(
node, self._open_close_attribute
diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py
index 39543154ef22..c94c6289aaa5 100644
--- a/homeassistant/components/homee/entity.py
+++ b/homeassistant/components/homee/entity.py
@@ -7,7 +7,9 @@ from pyHomee.const import AttributeType, NodeProfile, NodeState
from pyHomee.model import HomeeAttribute, HomeeNode
from websockets.exceptions import ConnectionClosed
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -26,7 +28,9 @@ class HomeeEntity(Entity):
_attr_has_entity_name = True
_attr_should_poll = False
- def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry
+ ) -> None:
"""Initialize the wrapper using a HomeeAttribute and target entity."""
self._attribute = attribute
self._attr_unique_id = (
@@ -43,11 +47,18 @@ class HomeeEntity(Entity):
else:
self._attr_device_info = DeviceInfo(
identifiers={
- (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}")
+ (
+ DOMAIN,
+ f"{entry.runtime_data.settings.uid}-{attribute.node_id}",
+ )
},
name=node.name,
model=get_name_for_enum(NodeProfile, node.profile),
- via_device=(DOMAIN, entry.runtime_data.settings.uid),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, entry.runtime_data.settings.uid),
+ config_entry_id=entry.entry_id,
+ ),
)
if attribute.name:
self._attr_name = attribute.name
@@ -106,7 +117,9 @@ class HomeeNodeEntity(Entity):
_attr_has_entity_name = True
_attr_should_poll = False
- def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry
+ ) -> None:
"""Initialize the wrapper using a HomeeNode and target entity."""
self._node = node
self._attr_unique_id = f"{entry.unique_id}-{node.id}"
@@ -123,7 +136,11 @@ class HomeeNodeEntity(Entity):
name=node.name,
model=get_name_for_enum(NodeProfile, node.profile),
sw_version=self._get_software_version(),
- via_device=(DOMAIN, entry.runtime_data.settings.uid),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, entry.runtime_data.settings.uid),
+ config_entry_id=entry.entry_id,
+ ),
)
self._host_connected = entry.runtime_data.connected
diff --git a/homeassistant/components/homee/event.py b/homeassistant/components/homee/event.py
index 2e2ae896e613..6a23c2c45995 100644
--- a/homeassistant/components/homee/event.py
+++ b/homeassistant/components/homee/event.py
@@ -54,13 +54,14 @@ EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = {
async def add_event_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee event entities."""
async_add_entities(
- HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type])
+ HomeeEvent(hass, attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in EVENT_DESCRIPTIONS
@@ -76,7 +77,9 @@ async def async_setup_entry(
) -> None:
"""Add event entities for homee."""
- await setup_homee_platform(add_event_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_event_entities, async_add_entities, config_entry
+ )
class HomeeEvent(HomeeEntity, EventEntity):
@@ -84,12 +87,13 @@ class HomeeEvent(HomeeEntity, EventEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: EventEntityDescription,
) -> None:
"""Initialize the homee event entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
self._attr_translation_key = description.key
if attribute.instance > 0:
diff --git a/homeassistant/components/homee/fan.py b/homeassistant/components/homee/fan.py
index 287fed257697..75594a198368 100644
--- a/homeassistant/components/homee/fan.py
+++ b/homeassistant/components/homee/fan.py
@@ -25,13 +25,14 @@ PARALLEL_UPDATES = 0
async def add_fan_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee fan entities."""
async_add_entities(
- HomeeFan(node, config_entry)
+ HomeeFan(hass, node, config_entry)
for node in nodes
if node.profile == NodeProfile.VENTILATION_CONTROL
)
@@ -44,7 +45,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Homee fan platform."""
- await setup_homee_platform(add_fan_entities, async_add_entities, config_entry)
+ await setup_homee_platform(hass, add_fan_entities, async_add_entities, config_entry)
class HomeeFan(HomeeNodeEntity, FanEntity):
@@ -56,9 +57,11 @@ class HomeeFan(HomeeNodeEntity, FanEntity):
speed_range = (1, 8)
_attr_speed_count = int_states_in_range(speed_range)
- def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry
+ ) -> None:
"""Initialize a Homee fan entity."""
- super().__init__(node, entry)
+ super().__init__(hass, node, entry)
self._speed_attribute: HomeeAttribute = cast(
HomeeAttribute, node.get_attribute_by_type(AttributeType.VENTILATION_LEVEL)
)
diff --git a/homeassistant/components/homee/helpers.py b/homeassistant/components/homee/helpers.py
index f9f675a631d0..0c819da0ebca 100644
--- a/homeassistant/components/homee/helpers.py
+++ b/homeassistant/components/homee/helpers.py
@@ -7,6 +7,7 @@ from typing import Any
from pyHomee.model import HomeeNode
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
@@ -15,8 +16,14 @@ _LOGGER = logging.getLogger(__name__)
async def setup_homee_platform(
+ hass: HomeAssistant,
add_platform_entities: Callable[
- [HomeeConfigEntry, AddConfigEntryEntitiesCallback, list[HomeeNode]],
+ [
+ HomeAssistant,
+ HomeeConfigEntry,
+ AddConfigEntryEntitiesCallback,
+ list[HomeeNode],
+ ],
Coroutine[Any, Any, None],
],
async_add_entities: AddConfigEntryEntitiesCallback,
@@ -24,13 +31,13 @@ async def setup_homee_platform(
) -> None:
"""Set up a homee platform."""
await add_platform_entities(
- config_entry, async_add_entities, config_entry.runtime_data.nodes
+ hass, config_entry, async_add_entities, config_entry.runtime_data.nodes
)
async def add_device(node: HomeeNode, add: bool) -> None:
"""Dynamically add entities."""
if add:
- await add_platform_entities(config_entry, async_add_entities, [node])
+ await add_platform_entities(hass, config_entry, async_add_entities, [node])
config_entry.async_on_unload(
config_entry.runtime_data.add_nodes_listener(add_device)
diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py
index 50be31bd81ee..ad60b7cfe09b 100644
--- a/homeassistant/components/homee/light.py
+++ b/homeassistant/components/homee/light.py
@@ -87,13 +87,14 @@ def decimal_to_rgb_list(color: float) -> list[int]:
async def add_light_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee light entities."""
async_add_entities(
- HomeeLight(node, light, config_entry)
+ HomeeLight(hass, node, light, config_entry)
for node in nodes
for light in get_light_attribute_sets(node)
if is_light_node(node)
@@ -107,7 +108,9 @@ async def async_setup_entry(
) -> None:
"""Add the homee platform for the light entity."""
- await setup_homee_platform(add_light_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_light_entities, async_add_entities, config_entry
+ )
class HomeeLight(HomeeNodeEntity, LightEntity):
@@ -115,12 +118,13 @@ class HomeeLight(HomeeNodeEntity, LightEntity):
def __init__(
self,
+ hass: HomeAssistant,
node: HomeeNode,
light: dict[AttributeType, HomeeAttribute],
entry: HomeeConfigEntry,
) -> None:
"""Initialize a Homee light."""
- super().__init__(node, entry)
+ super().__init__(hass, node, entry)
self._on_off_attr: HomeeAttribute = light[AttributeType.ON_OFF]
self._dimmer_attr: HomeeAttribute | None = light.get(
diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py
index e6a77dce9d7a..3567c41449d6 100644
--- a/homeassistant/components/homee/lock.py
+++ b/homeassistant/components/homee/lock.py
@@ -35,13 +35,14 @@ def _determine_lock_state_open(attribute: HomeeAttribute) -> float | None:
async def add_lock_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee lock entities."""
async_add_entities(
- HomeeLock(attribute, config_entry)
+ HomeeLock(hass, attribute, config_entry)
for node in nodes
for attribute in node.attributes
if (attribute.type == AttributeType.LOCK_STATE and attribute.editable)
@@ -55,7 +56,9 @@ async def async_setup_entry(
) -> None:
"""Add the homee platform for the lock component."""
- await setup_homee_platform(add_lock_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_lock_entities, async_add_entities, config_entry
+ )
class HomeeLock(HomeeEntity, LockEntity):
@@ -63,9 +66,11 @@ class HomeeLock(HomeeEntity, LockEntity):
_attr_name = None
- def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry
+ ) -> None:
"""Initialize the homee lock."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self._lock_state_open = _determine_lock_state_open(attribute)
if self._lock_state_open is not None:
self._attr_supported_features = LockEntityFeature.OPEN
diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py
index 8ca501c368c8..27b77b58a613 100644
--- a/homeassistant/components/homee/number.py
+++ b/homeassistant/components/homee/number.py
@@ -139,13 +139,14 @@ NUMBER_DESCRIPTIONS = {
async def add_number_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee number entities."""
async_add_entities(
- HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
+ HomeeNumber(hass, attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value"
@@ -159,7 +160,9 @@ async def async_setup_entry(
) -> None:
"""Add the homee platform for the number component."""
- await setup_homee_platform(add_number_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_number_entities, async_add_entities, config_entry
+ )
class HomeeNumber(HomeeEntity, NumberEntity):
@@ -169,12 +172,13 @@ class HomeeNumber(HomeeEntity, NumberEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: HomeeNumberEntityDescription,
) -> None:
"""Initialize a Homee number entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
self._attr_translation_key = description.key
self._attr_native_unit_of_measurement = (
diff --git a/homeassistant/components/homee/select.py b/homeassistant/components/homee/select.py
index 5673ba26889a..915591ecd211 100644
--- a/homeassistant/components/homee/select.py
+++ b/homeassistant/components/homee/select.py
@@ -31,13 +31,14 @@ SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = {
async def add_select_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee select entities."""
async_add_entities(
- HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type])
+ HomeeSelect(hass, attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in SELECT_DESCRIPTIONS and attribute.editable
@@ -51,7 +52,9 @@ async def async_setup_entry(
) -> None:
"""Add the homee platform for the select component."""
- await setup_homee_platform(add_select_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_select_entities, async_add_entities, config_entry
+ )
class HomeeSelect(HomeeEntity, SelectEntity):
@@ -59,12 +62,13 @@ class HomeeSelect(HomeeEntity, SelectEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: SelectEntityDescription,
) -> None:
"""Initialize a Homee select entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
assert description.options is not None
self._attr_options = description.options
diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py
index e345629c25d2..0285aee8b9b0 100644
--- a/homeassistant/components/homee/sensor.py
+++ b/homeassistant/components/homee/sensor.py
@@ -290,6 +290,7 @@ async def async_setup_entry(
"""Add the homee platform for the sensor components."""
async def add_sensor_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
@@ -300,14 +301,14 @@ async def async_setup_entry(
for node in nodes:
# Node properties that are sensors.
entities.extend(
- HomeeNodeSensor(node, config_entry, description)
+ HomeeNodeSensor(hass, node, config_entry, description)
for description in NODE_SENSOR_DESCRIPTIONS
)
# Node attributes that are sensors.
entities.extend(
HomeeSensor(
- attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
+ hass, attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
)
for attribute in node.attributes
if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable
@@ -316,7 +317,9 @@ async def async_setup_entry(
if entities:
async_add_entities(entities)
- await setup_homee_platform(add_sensor_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_sensor_entities, async_add_entities, config_entry
+ )
class HomeeSensor(HomeeEntity, SensorEntity):
@@ -326,12 +329,13 @@ class HomeeSensor(HomeeEntity, SensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: HomeeSensorEntityDescription,
) -> None:
"""Initialize a homee sensor entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
self._attr_translation_key = description.key
if attribute.instance > 0:
@@ -363,12 +367,13 @@ class HomeeNodeSensor(HomeeNodeEntity, SensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
node: HomeeNode,
entry: HomeeConfigEntry,
description: HomeeNodeSensorEntityDescription,
) -> None:
"""Initialize a homee node sensor entity."""
- super().__init__(node, entry)
+ super().__init__(hass, node, entry)
self.entity_description = description
self._node = node
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
diff --git a/homeassistant/components/homee/siren.py b/homeassistant/components/homee/siren.py
index d2bce747c97f..0617ec8f4c20 100644
--- a/homeassistant/components/homee/siren.py
+++ b/homeassistant/components/homee/siren.py
@@ -17,13 +17,14 @@ PARALLEL_UPDATES = 0
async def add_siren_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee siren entities."""
async_add_entities(
- HomeeSiren(attribute, config_entry)
+ HomeeSiren(hass, attribute, config_entry)
for node in nodes
for attribute in node.attributes
if attribute.type == AttributeType.SIREN
@@ -37,7 +38,9 @@ async def async_setup_entry(
) -> None:
"""Add siren entities for homee."""
- await setup_homee_platform(add_siren_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_siren_entities, async_add_entities, config_entry
+ )
class HomeeSiren(HomeeEntity, SirenEntity):
diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py
index b4858e7d0ec3..c41bc86c837a 100644
--- a/homeassistant/components/homee/switch.py
+++ b/homeassistant/components/homee/switch.py
@@ -16,6 +16,7 @@ from homeassistant.components.switch import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -70,13 +71,14 @@ SWITCH_DESCRIPTIONS: dict[AttributeType, HomeeSwitchEntityDescription] = {
async def add_switch_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee switch entities."""
async_add_entities(
- HomeeSwitch(attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type])
+ HomeeSwitch(hass, attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if (attribute.type in SWITCH_DESCRIPTIONS and attribute.editable)
@@ -97,9 +99,11 @@ async def async_setup_entry(
) -> None:
"""Set up the switch platform for the Homee component."""
- await setup_homee_platform(add_switch_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_switch_entities, async_add_entities, config_entry
+ )
async_add_entities(
- HomeegramSwitch(homeegram, config_entry)
+ HomeegramSwitch(hass, homeegram, config_entry)
for homeegram in config_entry.runtime_data.homeegrams
)
@@ -111,12 +115,13 @@ class HomeeSwitch(HomeeEntity, SwitchEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: HomeeSwitchEntityDescription,
) -> None:
"""Initialize a Homee switch entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
if attribute.instance == 0:
if attribute.type == AttributeType.ON_OFF:
@@ -156,17 +161,13 @@ class HomeegramSwitch(SwitchEntity):
_attr_has_entity_name = True
_attr_should_poll = False
- def __init__(self, homeegram: HomeeGram, entry: HomeeConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, homeegram: HomeeGram, entry: HomeeConfigEntry
+ ) -> None:
"""Initialize a homee Homeegram switch entity."""
self._homeegram = homeegram
self._entry = entry
self._attr_unique_id = f"{entry.unique_id}-hg-{homeegram.id}"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, f"{entry.unique_id}-homeegrams")},
- name="Homeegrams",
- model="Homeegram Switches",
- via_device=(DOMAIN, entry.runtime_data.settings.uid),
- )
self._attr_translation_key = "homeegram"
self._host_connected = entry.runtime_data.connected
self._attr_name = homeegram.name
@@ -175,6 +176,17 @@ class HomeegramSwitch(SwitchEntity):
homeegram
)
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{entry.unique_id}-homeegrams")},
+ name="Homeegrams",
+ model="Homeegram Switches",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, entry.runtime_data.settings.uid),
+ config_entry_id=entry.entry_id,
+ ),
+ )
+
@override
async def async_added_to_hass(self) -> None:
"""Add the Homeegram entity to home assistant."""
diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py
index 7d3783ac619f..57eadff3d287 100644
--- a/homeassistant/components/homee/valve.py
+++ b/homeassistant/components/homee/valve.py
@@ -29,13 +29,14 @@ VALVE_DESCRIPTIONS = {
async def add_valve_entities(
+ hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee valve entities."""
async_add_entities(
- HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
+ HomeeValve(hass, attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in VALVE_DESCRIPTIONS
@@ -49,7 +50,9 @@ async def async_setup_entry(
) -> None:
"""Add the homee platform for the valve component."""
- await setup_homee_platform(add_valve_entities, async_add_entities, config_entry)
+ await setup_homee_platform(
+ hass, add_valve_entities, async_add_entities, config_entry
+ )
class HomeeValve(HomeeEntity, ValveEntity):
@@ -59,12 +62,13 @@ class HomeeValve(HomeeEntity, ValveEntity):
def __init__(
self,
+ hass: HomeAssistant,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: ValveEntityDescription,
) -> None:
"""Initialize a Homee valve entity."""
- super().__init__(attribute, entry)
+ super().__init__(hass, attribute, entry)
self.entity_description = description
self._attr_translation_key = description.key
diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py
index 46934718b7ef..45656d0b8325 100644
--- a/homeassistant/components/homematicip_cloud/__init__.py
+++ b/homeassistant/components/homematicip_cloud/__init__.py
@@ -7,11 +7,7 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import (
- config_validation as cv,
- device_registry as dr,
- entity_registry as er,
-)
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -95,19 +91,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry)
EVENT_HOMEASSISTANT_STOP, hap.shutdown
)
- # Register hap as device in registry.
- device_registry = dr.async_get(hass)
-
- home = hap.home
- hapname = home.label if home.label != entry.unique_id else f"Home-{home.label}"
-
- device_registry.async_get_or_create(
- config_entry_id=entry.entry_id,
- identifiers={(DOMAIN, home.id)},
- manufacturer="eQ-3",
- # Add the name from config entry.
- name=hapname,
- )
return True
diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py
index 5d2f168967d9..8710673bf9cd 100644
--- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py
+++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py
@@ -1,7 +1,7 @@
"""Support for HomematicIP Cloud alarm control panel."""
import logging
-from typing import override
+from typing import TYPE_CHECKING, override
from homematicip.functionalHomes import SecurityAndAlarmHome
@@ -11,6 +11,7 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -51,12 +52,18 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity):
@override
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
+ if TYPE_CHECKING:
+ assert self.platform.config_entry is not None
return DeviceInfo(
identifiers={(DOMAIN, f"ACP {self._home.id}")},
manufacturer="eQ-3",
model=CONST_ALARM_CONTROL_PANEL_NAME,
name=self.name,
- via_device=(DOMAIN, self._home.id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.hass,
+ (DOMAIN, self._home.id),
+ config_entry_id=self.platform.config_entry.entry_id,
+ ),
)
@property
diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py
index 8d3855ecd654..d84a0d833871 100644
--- a/homeassistant/components/homematicip_cloud/climate.py
+++ b/homeassistant/components/homematicip_cloud/climate.py
@@ -1,6 +1,6 @@
"""Support for HomematicIP Cloud climate devices."""
-from typing import Any, override
+from typing import TYPE_CHECKING, Any, override
from homematicip.base.enums import AbsenceType
from homematicip.device import (
@@ -24,6 +24,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -104,12 +105,18 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
@override
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
+ if TYPE_CHECKING:
+ assert self.platform.config_entry is not None
return DeviceInfo(
identifiers={(DOMAIN, self._device.id)},
manufacturer="eQ-3",
model=self._device.modelType,
name=self._device.label,
- via_device=(DOMAIN, self._device.homeId),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.hass,
+ (DOMAIN, self._device.homeId),
+ config_entry_id=self.platform.config_entry.entry_id,
+ ),
)
@property
diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py
index 5e2947b902fe..f5e56c2715a6 100644
--- a/homeassistant/components/homematicip_cloud/entity.py
+++ b/homeassistant/components/homematicip_cloud/entity.py
@@ -2,7 +2,7 @@
import contextlib
import logging
-from typing import Any, override
+from typing import TYPE_CHECKING, Any, override
from homematicip.base.functionalChannels import FunctionalChannel
from homematicip.device import Device
@@ -145,6 +145,8 @@ class HomematicipGenericEntity(Entity):
if device_name and home_name:
device_name = f"{home_name} {device_name}"
+ if TYPE_CHECKING:
+ assert self.platform.config_entry is not None
return DeviceInfo(
identifiers={
# Serial numbers of Homematic IP device
@@ -155,7 +157,11 @@ class HomematicipGenericEntity(Entity):
name=device_name,
sw_version=self._device.firmwareVersion,
# Link to the homematic ip access point.
- via_device=(DOMAIN, home_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.hass,
+ (DOMAIN, home_id),
+ config_entry_id=self.platform.config_entry.entry_id,
+ ),
)
return None
diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py
index 65da63cd344f..ad0699f5d89a 100644
--- a/homeassistant/components/homematicip_cloud/hap.py
+++ b/homeassistant/components/homematicip_cloud/hap.py
@@ -19,9 +19,17 @@ import homeassistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.httpx_client import get_async_client
-from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN, PLATFORMS
+from .const import (
+ DOMAIN,
+ HMIPC_AUTHTOKEN,
+ HMIPC_HAPID,
+ HMIPC_NAME,
+ HMIPC_PIN,
+ PLATFORMS,
+)
from .errors import HmipcConnectionError
_LOGGER = logging.getLogger(__name__)
@@ -138,12 +146,32 @@ class HomematicipHAP:
"Connected to HomematicIP with HAP %s", self.config_entry.unique_id
)
+ # Register the access point as a device before forwarding platforms so
+ # child devices can resolve it as their via_device.
+ self._async_register_access_point()
+
await self.hass.config_entries.async_forward_entry_setups(
self.config_entry, PLATFORMS
)
return True
+ @callback
+ def _async_register_access_point(self) -> None:
+ """Register the access point as a device in the device registry."""
+ home = self.home
+ hapname = (
+ home.label
+ if home.label != self.config_entry.unique_id
+ else f"Home-{home.label}"
+ )
+ dr.async_get(self.hass).async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ identifiers={(DOMAIN, home.id)},
+ manufacturer="eQ-3",
+ name=hapname,
+ )
+
@callback
def async_update(self, *args, **kwargs) -> None:
"""Async update the home device.
diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py
index 50ca88bca4a0..d872d2b7572b 100644
--- a/homeassistant/components/homewizard/__init__.py
+++ b/homeassistant/components/homewizard/__init__.py
@@ -17,6 +17,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from .const import DOMAIN, PLATFORMS
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
+from .entity import create_main_device_info
async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool:
@@ -60,6 +61,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -
):
hass.config_entries.flow.async_abort(progress_flow["flow_id"])
+ # Register the main device up front so external sub-device sensors can
+ # resolve it as their via_device parent regardless of entity add order.
+ if coordinator.data.device.serial is not None:
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **create_main_device_info(coordinator),
+ )
+
# Finalize
entry.async_on_unload(coordinator.api.close)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py
index 3523f4b9721d..26dbc1032a19 100644
--- a/homeassistant/components/homewizard/entity.py
+++ b/homeassistant/components/homewizard/entity.py
@@ -8,6 +8,23 @@ from .const import DOMAIN
from .coordinator import HWEnergyDeviceUpdateCoordinator
+def create_main_device_info(
+ coordinator: HWEnergyDeviceUpdateCoordinator,
+) -> DeviceInfo:
+ """Return the device info for the main HomeWizard device."""
+ device_info = DeviceInfo(
+ manufacturer="HomeWizard",
+ sw_version=coordinator.data.device.firmware_version,
+ model_id=coordinator.data.device.product_type,
+ model=coordinator.data.device.model_name,
+ )
+ if (serial_number := coordinator.data.device.serial) is not None:
+ device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, serial_number)}
+ device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)}
+ device_info[ATTR_SERIAL_NUMBER] = serial_number
+ return device_info
+
+
class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]):
"""Defines a HomeWizard entity."""
@@ -16,16 +33,4 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]):
def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None:
"""Initialize the HomeWizard entity."""
super().__init__(coordinator)
- self._attr_device_info = DeviceInfo(
- manufacturer="HomeWizard",
- sw_version=coordinator.data.device.firmware_version,
- model_id=coordinator.data.device.product_type,
- model=coordinator.data.device.model_name,
- )
-
- if (serial_number := coordinator.data.device.serial) is not None:
- self._attr_device_info[ATTR_CONNECTIONS] = {
- (CONNECTION_NETWORK_MAC, serial_number)
- }
- self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)}
- self._attr_device_info[ATTR_SERIAL_NUMBER] = serial_number
+ self._attr_device_info = create_main_device_info(coordinator)
diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py
index 9e632da96350..e36799113da2 100644
--- a/homeassistant/components/homewizard/sensor.py
+++ b/homeassistant/components/homewizard/sensor.py
@@ -16,7 +16,6 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_VIA_DEVICE,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
@@ -31,7 +30,10 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.device_registry import (
+ DeviceInfo,
+ async_get_device_id_by_identifier,
+)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
@@ -839,9 +841,10 @@ class HomeWizardExternalSensorEntity(HomeWizardEntity, SensorEntity):
serial_number=device_unique_id,
)
if coordinator.data.device.serial is not None:
- self._attr_device_info[ATTR_VIA_DEVICE] = (
- DOMAIN,
- coordinator.data.device.serial,
+ self._attr_device_info["via_device_id"] = async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.data.device.serial),
+ config_entry_id=coordinator.config_entry.entry_id,
)
@property
diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py
index 86fef4d56bd1..8244db1b067f 100644
--- a/homeassistant/components/hue/device_trigger.py
+++ b/homeassistant/components/hue/device_trigger.py
@@ -1,10 +1,12 @@
"""Provides device automations for Philips Hue events."""
+import logging
from typing import TYPE_CHECKING, Any
from homeassistant.components.device_automation import InvalidDeviceAutomationConfig
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_DEVICE_ID
-from homeassistant.core import CALLBACK_TYPE
+from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.typing import ConfigType
@@ -26,6 +28,8 @@ if TYPE_CHECKING:
from .bridge import HueConfigEntry
+LOGGER = logging.getLogger(__name__)
+
async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
@@ -64,20 +68,27 @@ async def async_attach_trigger(
if (device_entry := dev_reg.async_get(device_id)) is None:
raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid")
- entry: HueConfigEntry
- for entry in hass.config_entries.async_loaded_entries(DOMAIN):
- if entry.entry_id not in device_entry.config_entries:
- continue
- bridge = entry.runtime_data
- if bridge.api_version == 1:
- return await async_attach_trigger_v1(
- bridge, device_entry, config, action, trigger_info
- )
- return await async_attach_trigger_v2(
- bridge, device_entry, config, action, trigger_info
+ entry: HueConfigEntry | None = next(
+ (
+ entry
+ for entry in hass.config_entries.async_entries(DOMAIN)
+ if entry.entry_id in device_entry.config_entries
+ ),
+ None,
+ )
+ if entry is None:
+ raise InvalidDeviceAutomationConfig(
+ f"Device ID {device_id} is not found on any Hue bridge"
)
- raise InvalidDeviceAutomationConfig(
- f"Device ID {device_id} is not found on any Hue bridge"
+
+ if entry.state is not ConfigEntryState.LOADED:
+ # The bridge is still setting up when automations are attached at startup.
+ return _async_attach_on_entry_load(
+ hass, entry, device_entry, config, action, trigger_info
+ )
+
+ return await _async_attach_bridge_trigger(
+ entry, device_entry, config, action, trigger_info
)
@@ -104,3 +115,72 @@ async def async_get_triggers(
return async_get_triggers_v1(bridge, device_entry)
return async_get_triggers_v2(bridge, device_entry)
return []
+
+
+async def _async_attach_bridge_trigger(
+ entry: HueConfigEntry,
+ device_entry: dr.DeviceEntry,
+ config: ConfigType,
+ action: TriggerActionType,
+ trigger_info: TriggerInfo,
+) -> CALLBACK_TYPE:
+ """Attach the trigger to the bridge of a loaded config entry."""
+ bridge = entry.runtime_data
+ if bridge.api_version == 1:
+ return await async_attach_trigger_v1(
+ bridge, device_entry, config, action, trigger_info
+ )
+ return await async_attach_trigger_v2(
+ bridge, device_entry, config, action, trigger_info
+ )
+
+
+@callback
+def _async_attach_on_entry_load(
+ hass: HomeAssistant,
+ entry: HueConfigEntry,
+ device_entry: dr.DeviceEntry,
+ config: ConfigType,
+ action: TriggerActionType,
+ trigger_info: TriggerInfo,
+) -> CALLBACK_TYPE:
+ """Attach the trigger as soon as the given config entry is loaded.
+
+ The returned callback detaches the trigger and is safe to call while the
+ config entry is still loading.
+ """
+ remove_trigger: CALLBACK_TYPE | None = None
+ attach_scheduled = False
+
+ async def _async_attach() -> None:
+ nonlocal remove_trigger
+ try:
+ remove_trigger = await _async_attach_bridge_trigger(
+ entry, device_entry, config, action, trigger_info
+ )
+ except InvalidDeviceAutomationConfig as err:
+ LOGGER.error(
+ "Got error '%s' when setting up triggers for %s",
+ err,
+ trigger_info["name"],
+ )
+
+ @callback
+ def _handle_entry_state_change() -> None:
+ nonlocal attach_scheduled
+ # Unsubscribing here would mutate the list this callback is iterated from,
+ # so the subscription is kept until the trigger is detached.
+ if attach_scheduled or entry.state is not ConfigEntryState.LOADED:
+ return
+ attach_scheduled = True
+ hass.async_create_task(_async_attach())
+
+ unsub_state_change = entry.async_on_state_change(_handle_entry_state_change)
+
+ @callback
+ def _remove() -> None:
+ unsub_state_change()
+ if remove_trigger is not None:
+ remove_trigger()
+
+ return _remove
diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py
index c0a6bb2ba1bd..16589d25ad61 100644
--- a/homeassistant/components/hue/v1/device_trigger.py
+++ b/homeassistant/components/hue/v1/device_trigger.py
@@ -155,7 +155,9 @@ async def async_attach_trigger(
hue_event = _get_hue_event_from_device_id(hass, device_entry.id)
if hue_event is None:
- raise InvalidDeviceAutomationConfig
+ raise InvalidDeviceAutomationConfig(
+ f"Device {device_entry.id} is not available on the Hue bridge"
+ )
trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE])
diff --git a/homeassistant/components/hue/v1/helpers.py b/homeassistant/components/hue/v1/helpers.py
index 1cf5bc45c55d..c5abd58341d7 100644
--- a/homeassistant/components/hue/v1/helpers.py
+++ b/homeassistant/components/hue/v1/helpers.py
@@ -25,9 +25,7 @@ async def remove_devices(bridge, api_ids, current):
(DOMAIN, entity.device_id), bridge.config_entry.entry_id
)
if device is not None:
- dev_registry.async_update_device(
- device.id, remove_config_entry_id=bridge.config_entry.entry_id
- )
+ dev_registry.async_remove_device(device.id)
for item_id in removed_items:
del current[item_id]
diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py
index 91b460bb7a64..1fe1c7b070e7 100644
--- a/homeassistant/components/husqvarna_automower/coordinator.py
+++ b/homeassistant/components/husqvarna_automower/coordinator.py
@@ -234,10 +234,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
(DOMAIN, mower_id), self.config_entry.entry_id
)
if dev is not None:
- device_registry.async_update_device(
- device_id=dev.id,
- remove_config_entry_id=self.config_entry.entry_id,
- )
+ device_registry.async_remove_device(dev.id)
new_devices = current_devices - registered_devices
if new_devices:
diff --git a/homeassistant/components/imou/coordinator.py b/homeassistant/components/imou/coordinator.py
index 594e35318a26..01b09cc344b2 100644
--- a/homeassistant/components/imou/coordinator.py
+++ b/homeassistant/components/imou/coordinator.py
@@ -152,10 +152,7 @@ class ImouDataUpdateCoordinator(DataUpdateCoordinator[None]):
if device := device_registry.async_get_device_by_identifier(
(DOMAIN, device_key), self.config_entry.entry_id
):
- 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)
if new_keys := current_keys - known_keys:
_LOGGER.debug("New Imou device(s) found: %s", ", ".join(new_keys))
diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py
index 85f889ac6693..d4909780e07b 100644
--- a/homeassistant/components/incomfort/climate.py
+++ b/homeassistant/components/incomfort/climate.py
@@ -12,6 +12,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -70,9 +71,12 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
name=f"Thermostat {room.room_no}",
)
if coordinator.unique_id:
- self._attr_device_info["via_device"] = (
- DOMAIN,
- coordinator.config_entry.entry_id,
+ self._attr_device_info["via_device_id"] = (
+ dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.entry_id),
+ config_entry_id=coordinator.config_entry.entry_id,
+ )
)
@property
diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py
index 1e0d357e8e08..c5839025a52c 100644
--- a/homeassistant/components/incomfort/entity.py
+++ b/homeassistant/components/incomfort/entity.py
@@ -2,6 +2,7 @@
from incomfortclient import Heater
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -29,7 +30,10 @@ class IncomfortBoilerEntity(IncomfortEntity):
serial_number=heater.serial_no,
)
if coordinator.unique_id:
- self._attr_device_info["via_device"] = (
- DOMAIN,
- coordinator.config_entry.entry_id,
+ self._attr_device_info["via_device_id"] = (
+ dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.entry_id),
+ config_entry_id=coordinator.config_entry.entry_id,
+ )
)
diff --git a/homeassistant/components/insteon/api/aldb.py b/homeassistant/components/insteon/api/aldb.py
index ffc846fe6c32..759172329c8f 100644
--- a/homeassistant/components/insteon/api/aldb.py
+++ b/homeassistant/components/insteon/api/aldb.py
@@ -12,6 +12,7 @@ from homeassistant.helpers import device_registry as dr
from ..const import DEVICE_ADDRESS, ID, INSTEON_DEVICE_NOT_FOUND, TYPE
from ..utils import async_device_name
+from .config import get_insteon_config_entry
from .device import notify_device_not_found
ALDB_RECORD = "record"
@@ -32,7 +33,7 @@ ALDB_RECORD_SCHEMA = vol.Schema(
)
-async def async_aldb_record_to_dict(dev_registry, record, dirty=False):
+async def async_aldb_record_to_dict(dev_registry, record, config_entry_id, dirty=False):
"""Convert an ALDB record to a dict."""
return ALDB_RECORD_SCHEMA(
{
@@ -42,7 +43,9 @@ async def async_aldb_record_to_dict(dev_registry, record, dirty=False):
"highwater": record.is_high_water_mark,
"group": record.group,
"target": str(record.target),
- "target_name": await async_device_name(dev_registry, record.target),
+ "target_name": await async_device_name(
+ dev_registry, record.target, config_entry_id
+ ),
"data1": record.data1,
"data2": record.data2,
"data3": record.data3,
@@ -88,10 +91,11 @@ async def websocket_get_aldb(
changed_records = list(device.aldb.pending_changes.keys())
dev_registry = dr.async_get(hass)
+ config_entry_id = get_insteon_config_entry(hass).entry_id
records = [
await async_aldb_record_to_dict(
- dev_registry, aldb[mem_addr], mem_addr in changed_records
+ dev_registry, aldb[mem_addr], config_entry_id, mem_addr in changed_records
)
for mem_addr in aldb
]
diff --git a/homeassistant/components/insteon/api/config.py b/homeassistant/components/insteon/api/config.py
index 70a827d64cfc..4db44bd95eeb 100644
--- a/homeassistant/components/insteon/api/config.py
+++ b/homeassistant/components/insteon/api/config.py
@@ -137,12 +137,16 @@ def remove_device_override(hass: HomeAssistant, address: Address):
async def async_link_to_dict(
- address: Address, record: ALDBRecord, dev_registry: dr.DeviceRegistry, status=None
+ address: Address,
+ record: ALDBRecord,
+ dev_registry: dr.DeviceRegistry,
+ config_entry_id: str,
+ status=None,
) -> dict[str, str | int]:
"""Convert a link to a dictionary."""
link_dict: dict[str, str | int] = {}
- device_name = await async_device_name(dev_registry, address)
- target_name = await async_device_name(dev_registry, record.target)
+ device_name = await async_device_name(dev_registry, address, config_entry_id)
+ target_name = await async_device_name(dev_registry, record.target, config_entry_id)
link_dict["address"] = str(address)
link_dict["device_name"] = device_name or str(address)
link_dict["mem_addr"] = record.mem_addr
@@ -311,8 +315,9 @@ async def websocket_get_broken_links(
"""Get any broken links between devices."""
broken_links = get_broken_links(devices=devices)
dev_registry = dr.async_get(hass)
+ config_entry_id = get_insteon_config_entry(hass).entry_id
broken_links_list = [
- await async_link_to_dict(address, record, dev_registry, status)
+ await async_link_to_dict(address, record, dev_registry, config_entry_id, status)
for address, record, status in broken_links
if status != LinkStatus.MISSING_TARGET
]
diff --git a/homeassistant/components/insteon/services.py b/homeassistant/components/insteon/services.py
index 448a268e6077..ad215fa42f39 100644
--- a/homeassistant/components/insteon/services.py
+++ b/homeassistant/components/insteon/services.py
@@ -217,7 +217,10 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
signal = f"{address.id}_{SIGNAL_REMOVE_ENTITY}"
async_dispatcher_send(hass, signal)
dev_registry = dr.async_get(hass)
- device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))})
+ config_entry = hass.config_entries.async_entries(DOMAIN)[0]
+ device = dev_registry.async_get_device_by_identifier(
+ (DOMAIN, str(address)), config_entry.entry_id
+ )
if device:
dev_registry.async_remove_device(device.id)
diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py
index ffda09b4ce99..7197c442d8bf 100644
--- a/homeassistant/components/insteon/utils.py
+++ b/homeassistant/components/insteon/utils.py
@@ -190,9 +190,13 @@ def compute_device_name(ha_device) -> str:
return ha_device.name_by_user or ha_device.name
-async def async_device_name(dev_registry: dr.DeviceRegistry, address: Address) -> str:
+async def async_device_name(
+ dev_registry: dr.DeviceRegistry, address: Address, config_entry_id: str
+) -> str:
"""Get the Insteon device name from a device registry id."""
- ha_device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))})
+ ha_device = dev_registry.async_get_device_by_identifier(
+ (DOMAIN, str(address)), config_entry_id
+ )
if not ha_device:
if device := devices[address]:
return f"{device.description} ({device.model})"
diff --git a/homeassistant/components/intelliclima/manifest.json b/homeassistant/components/intelliclima/manifest.json
index 97c90f65e96e..37c2a3ab0045 100644
--- a/homeassistant/components/intelliclima/manifest.json
+++ b/homeassistant/components/intelliclima/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
- "requirements": ["pyintelliclima==0.3.1"]
+ "requirements": ["pyintelliclima==0.4.0"]
}
diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py
index 938a53e59584..b7cdb9c6f925 100644
--- a/homeassistant/components/isy994/__init__.py
+++ b/homeassistant/components/isy994/__init__.py
@@ -138,12 +138,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
) from err
isy_data = entry.runtime_data = IsyData()
- _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier)
+ isy_data.root = isy
+ _async_get_or_create_isy_device_in_registry(hass, entry, isy)
+ via_device_id = dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, isy.uuid), config_entry_id=entry.entry_id
+ )
+ _categorize_nodes(
+ isy_data, isy.nodes, ignore_identifier, sensor_identifier, via_device_id
+ )
_categorize_programs(isy_data, isy.programs)
# Gather ISY Variables to be added.
if isy.variables.children:
isy_data.devices[CONF_VARIABLES] = _create_service_device_info(
- isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES
+ isy,
+ name=CONF_VARIABLES.title(),
+ unique_id=CONF_VARIABLES,
+ via_device_id=via_device_id,
)
numbers = isy_data.variables[Platform.NUMBER]
for vtype, _, vid in isy.variables.children:
@@ -152,7 +162,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
isy.conf[CONFIG_NETWORKING] or isy.conf.get(CONFIG_PORTAL)
) and isy.networking.nobjs:
isy_data.devices[CONF_NETWORK] = _create_service_device_info(
- isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK
+ isy,
+ name=CONFIG_NETWORKING,
+ unique_id=CONF_NETWORK,
+ via_device_id=via_device_id,
)
for resource in isy.networking.nobjs:
isy_data.net_resources.append(resource)
@@ -160,9 +173,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
# Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs
LOGGER.debug(repr(isy.clock))
- isy_data.root = isy
- _async_get_or_create_isy_device_in_registry(hass, entry, isy)
-
# Load platforms for the devices in the ISY controller that we support.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -202,9 +212,11 @@ def _async_get_or_create_isy_device_in_registry(
)
-def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceInfo:
+def _create_service_device_info(
+ isy: ISY, name: str, unique_id: str, via_device_id: str | None
+) -> DeviceInfo:
"""Create device info for ISY service devices."""
- return DeviceInfo(
+ device_info = DeviceInfo(
identifiers={
(
DOMAIN,
@@ -216,9 +228,11 @@ def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceIn
model=isy.conf[ISY_CONF_MODEL],
sw_version=isy.conf[ISY_CONF_FIRMWARE],
configuration_url=isy.conn.url,
- via_device=(DOMAIN, isy.uuid),
entry_type=DeviceEntryType.SERVICE,
)
+ if via_device_id is not None:
+ device_info["via_device_id"] = via_device_id
+ return device_info
async def async_unload_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py
index 9da04c45a254..ebd9a38e1347 100644
--- a/homeassistant/components/isy994/helpers.py
+++ b/homeassistant/components/isy994/helpers.py
@@ -288,17 +288,18 @@ def _add_backlight_if_supported(isy_data: IsyData, node: Node) -> None:
isy_data.aux_properties[Platform.NUMBER].append((node, CMD_BACKLIGHT))
-def _generate_device_info(node: Node) -> DeviceInfo:
+def _generate_device_info(node: Node, via_device_id: str | None) -> DeviceInfo:
"""Generate the device info for a root node device."""
isy = node.isy
device_info = DeviceInfo(
identifiers={(DOMAIN, f"{isy.uuid}_{node.address}")},
manufacturer=node.protocol.title(),
name=node.name,
- via_device=(DOMAIN, isy.uuid),
configuration_url=isy.conn.url,
suggested_area=node.folder,
)
+ if via_device_id is not None:
+ device_info["via_device_id"] = via_device_id
# ISYv5 Device Types can provide model and manufacturer
model: str = str(node.address).rpartition(" ")[0] or node.address
@@ -328,7 +329,11 @@ def _generate_device_info(node: Node) -> DeviceInfo:
def _categorize_nodes(
- isy_data: IsyData, nodes: Nodes, ignore_identifier: str, sensor_identifier: str
+ isy_data: IsyData,
+ nodes: Nodes,
+ ignore_identifier: str,
+ sensor_identifier: str,
+ via_device_id: str | None,
) -> None:
"""Sort the nodes to their proper platforms."""
for path, node in nodes:
@@ -339,7 +344,7 @@ def _categorize_nodes(
if hasattr(node, "parent_node") and node.parent_node is None:
# This is a physical device / parent node
- isy_data.devices[node.address] = _generate_device_info(node)
+ isy_data.devices[node.address] = _generate_device_info(node, via_device_id)
isy_data.root_nodes[Platform.BUTTON].append(node)
# Any parent node can have communication errors:
isy_data.aux_properties[Platform.SENSOR].append((node, PROP_COMMS_ERROR))
diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py
index 4f709ab98fec..154820234b49 100644
--- a/homeassistant/components/keenetic_ndms2/__init__.py
+++ b/homeassistant/components/keenetic_ndms2/__init__.py
@@ -70,11 +70,15 @@ async def async_unload_entry(
_LOGGER.debug("Removing entity %s", entity_entry.entity_id)
ent_reg.async_remove(entity_entry.entity_id)
- if entity_entry.device_id:
- dev_reg.async_update_device(
- entity_entry.device_id,
- remove_config_entry_id=config_entry.entry_id,
- )
+ # The tracker attaches to a device found by MAC, which may be
+ # owned by another integration; only remove it if we own it.
+ if (
+ entity_entry.device_id
+ and (device := dev_reg.async_get(entity_entry.device_id))
+ is not None
+ and device.config_entry_id == config_entry.entry_id
+ ):
+ dev_reg.async_remove_device(device.id)
_LOGGER.debug("Finished cleaning device_tracker entities")
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index 4f4def15dff0..bf5194e1a7bb 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.18.0",
"xknxproject==3.10.0",
- "knx-frontend==2026.7.23.145751",
+ "knx-frontend==2026.8.2.133643",
"knx-telegram-store[sqlite,postgres]==0.11.2"
],
"single_config_entry": true
diff --git a/homeassistant/components/libre_hardware_monitor/coordinator.py b/homeassistant/components/libre_hardware_monitor/coordinator.py
index 79d8a5906773..4f74e94138ed 100644
--- a/homeassistant/components/libre_hardware_monitor/coordinator.py
+++ b/homeassistant/components/libre_hardware_monitor/coordinator.py
@@ -136,9 +136,6 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
_LOGGER.debug(
"Removing device: %s", self._previous_devices[device_id]
)
- device_registry.async_update_device(
- device_id=device.id,
- remove_config_entry_id=self._entry_id,
- )
+ device_registry.async_remove_device(device.id)
self._previous_devices = detected_devices
diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py
index 0c30aa4b73e2..f5461be88a23 100644
--- a/homeassistant/components/litejet/__init__.py
+++ b/homeassistant/components/litejet/__init__.py
@@ -8,8 +8,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
-from .const import PLATFORMS
+from .const import DOMAIN, PLATFORMS
type LiteJetConfigEntry = ConfigEntry[pylitejet.LiteJet]
@@ -42,6 +43,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiteJetConfigEntry) -> b
entry.runtime_data = system
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, f"{entry.entry_id}_mcp")},
+ name="LiteJet",
+ manufacturer="Centralite",
+ model=system.model_name,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py
index 0340bd64ccfa..09817d5feed1 100644
--- a/homeassistant/components/litejet/light.py
+++ b/homeassistant/components/litejet/light.py
@@ -13,6 +13,7 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -34,7 +35,7 @@ async def async_setup_entry(
entities = []
for index in system.loads():
name = await system.get_load_name(index)
- entities.append(LiteJetLight(config_entry, system, index, name))
+ entities.append(LiteJetLight(hass, config_entry, system, index, name))
async_add_entities(entities, True)
@@ -50,7 +51,12 @@ class LiteJetLight(LightEntity):
_attr_name = None
def __init__(
- self, config_entry: LiteJetConfigEntry, system: LiteJet, index: int, name: str
+ self,
+ hass: HomeAssistant,
+ config_entry: LiteJetConfigEntry,
+ system: LiteJet,
+ index: int,
+ name: str,
) -> None:
"""Initialize a LiteJet light."""
self._config_entry = config_entry
@@ -63,7 +69,11 @@ class LiteJetLight(LightEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{config_entry.entry_id}_light_{index}")},
name=name,
- via_device=(DOMAIN, f"{config_entry.entry_id}_mcp"),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, f"{config_entry.entry_id}_mcp"),
+ config_entry_id=config_entry.entry_id,
+ ),
)
@override
diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py
index ee4883a9d8a8..083c94a691b3 100644
--- a/homeassistant/components/litejet/switch.py
+++ b/homeassistant/components/litejet/switch.py
@@ -7,6 +7,7 @@ from pylitejet import LiteJet, LiteJetError
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -28,7 +29,7 @@ async def async_setup_entry(
entities = []
for i in system.button_switches():
name = await system.get_switch_name(i)
- entities.append(LiteJetSwitch(config_entry.entry_id, system, i, name))
+ entities.append(LiteJetSwitch(hass, config_entry.entry_id, system, i, name))
async_add_entities(entities, True)
@@ -41,7 +42,9 @@ class LiteJetSwitch(SwitchEntity):
_attr_entity_registry_enabled_default = False
_attr_device_class = SwitchDeviceClass.SWITCH
- def __init__(self, entry_id: str, system: LiteJet, i: int, name: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, entry_id: str, system: LiteJet, i: int, name: str
+ ) -> None:
"""Initialize a LiteJet switch."""
self._lj = system
self._index = i
@@ -55,7 +58,9 @@ class LiteJetSwitch(SwitchEntity):
identifiers={(DOMAIN, f"{entry_id}_keypad_{keypad_number}")},
name=system.get_switch_keypad_name(i),
manufacturer="Centralite",
- via_device=(DOMAIN, f"{entry_id}_mcp"),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, f"{entry_id}_mcp"), config_entry_id=entry_id
+ ),
)
@override
diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py
index ad9b1e2ef610..8cbf0480116d 100644
--- a/homeassistant/components/litterrobot/coordinator.py
+++ b/homeassistant/components/litterrobot/coordinator.py
@@ -85,10 +85,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
(DOMAIN, device_id), self.config_entry.entry_id
)
if device:
- 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)
self.previous_members = current_members
@override
diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py
index 73ecf37efd41..8594a0b20ca5 100644
--- a/homeassistant/components/lookin/__init__.py
+++ b/homeassistant/components/lookin/__init__.py
@@ -32,6 +32,7 @@ from .const import (
TYPE_TO_PLATFORM,
)
from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator
+from .entity import _lookin_device_to_device_info
from .models import LookinConfigEntry, LookinData
LOGGER = logging.getLogger(__name__)
@@ -182,6 +183,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bo
device_coordinators=device_coordinators,
)
+ # Register the lookin device so entities on the controlled devices can link
+ # to it via via_device_id, regardless of which platform is set up first.
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **_lookin_device_to_device_info(lookin_device, host),
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py
index 4a7e53351e85..d4460612fd42 100644
--- a/homeassistant/components/lookin/entity.py
+++ b/homeassistant/components/lookin/entity.py
@@ -14,6 +14,7 @@ from aiolookin import (
)
from aiolookin.models import Device, UDPCommandType, UDPEvent
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -37,15 +38,17 @@ def _lookin_device_to_device_info(lookin_device: Device, host: str) -> DeviceInf
def _lookin_controlled_device_to_device_info(
- lookin_device: Device, uuid: str, device: Climate | Remote, host: str
+ uuid: str, device: Climate | Remote, host: str, via_device_id: str | None
) -> DeviceInfo:
- return DeviceInfo(
+ device_info = DeviceInfo(
identifiers={(DOMAIN, uuid)},
name=device.name,
model=device.device_type,
- via_device=(DOMAIN, lookin_device.id),
configuration_url=f"http://{host}/data/{uuid}",
)
+ if via_device_id is not None:
+ device_info["via_device_id"] = via_device_id
+ return device_info
class LookinDeviceMixIn:
@@ -113,7 +116,14 @@ class LookinCoordinatorEntity(
self._set_lookin_device_attrs(lookin_data)
self._set_lookin_entity_attrs(uuid, device, lookin_data)
self._attr_device_info = _lookin_controlled_device_to_device_info(
- self._lookin_device, uuid, device, lookin_data.host
+ uuid,
+ device,
+ lookin_data.host,
+ dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self._lookin_device.id),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
self._attr_unique_id = uuid
self._attr_name = device.name
diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py
index cd883a65a245..b3c5749732b0 100644
--- a/homeassistant/components/lupusec/__init__.py
+++ b/homeassistant/components/lupusec/__init__.py
@@ -9,6 +9,9 @@ from lupupy.exceptions import LupusecException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -45,6 +48,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LupusecConfigEntry) -> b
entry.runtime_data = lupusec_system
+ alarm = await hass.async_add_executor_job(lupusec_system.get_alarm)
+
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, entry.entry_id)},
+ name=alarm.name,
+ manufacturer="Lupus Electronics",
+ model=f"Lupusec-XT{lupusec_system.model}",
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py
index 07accdc195fe..494cc0d85153 100644
--- a/homeassistant/components/lupusec/binary_sensor.py
+++ b/homeassistant/components/lupusec/binary_sensor.py
@@ -37,7 +37,7 @@ async def async_setup_entry(
devices = await hass.async_add_executor_job(partial_func)
async_add_entities(
- LupusecBinarySensor(device, config_entry.entry_id) for device in devices
+ LupusecBinarySensor(hass, device, config_entry.entry_id) for device in devices
)
diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py
index 8cfb559b84fe..2bfb4cf1a037 100644
--- a/homeassistant/components/lupusec/entity.py
+++ b/homeassistant/components/lupusec/entity.py
@@ -2,6 +2,8 @@
import lupupy
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -26,17 +28,20 @@ class LupusecDevice(Entity):
class LupusecBaseSensor(LupusecDevice):
"""Lupusec Sensor base entity."""
- def __init__(self, device: lupupy.devices.LupusecDevice, entry_id: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: lupupy.devices.LupusecDevice, entry_id: str
+ ) -> None:
"""Initialize the LupusecBaseSensor."""
super().__init__(device)
-
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.device_id)},
name=device.name,
manufacturer="Lupus Electronics",
serial_number=device.device_id,
model=TYPE_TRANSLATION.get(device.type, device.type),
- via_device=(DOMAIN, entry_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, entry_id), config_entry_id=entry_id
+ ),
)
def get_type_name(self) -> str:
diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py
index 0a36cde83168..623b47b3f2d6 100644
--- a/homeassistant/components/lupusec/switch.py
+++ b/homeassistant/components/lupusec/switch.py
@@ -31,7 +31,7 @@ async def async_setup_entry(
devices = await hass.async_add_executor_job(partial_func)
async_add_entities(
- LupusecSwitch(device, config_entry.entry_id) for device in devices
+ LupusecSwitch(hass, device, config_entry.entry_id) for device in devices
)
diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py
index 760be15abaa4..e7f8d58b9f4e 100644
--- a/homeassistant/components/lutron/binary_sensor.py
+++ b/homeassistant/components/lutron/binary_sensor.py
@@ -29,7 +29,9 @@ async def async_setup_entry(
entry_data = config_entry.runtime_data
async_add_entities(
[
- LutronOccupancySensor(area_name, device, entry_data.client)
+ LutronOccupancySensor(
+ hass, area_name, device, entry_data.client, config_entry.entry_id
+ )
for area_name, device in entry_data.binary_sensors
],
True,
diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py
index 8e5938fe2b57..9f80a6705a20 100644
--- a/homeassistant/components/lutron/cover.py
+++ b/homeassistant/components/lutron/cover.py
@@ -33,7 +33,9 @@ async def async_setup_entry(
entry_data = config_entry.runtime_data
async_add_entities(
[
- LutronCover(area_name, device, entry_data.client)
+ LutronCover(
+ hass, area_name, device, entry_data.client, config_entry.entry_id
+ )
for area_name, device in entry_data.covers
],
True,
diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py
index b8d96849ed80..6d5d42948830 100644
--- a/homeassistant/components/lutron/entity.py
+++ b/homeassistant/components/lutron/entity.py
@@ -4,7 +4,8 @@ from typing import override
from pylutron import Keypad, Lutron, LutronEntity, LutronEvent
-from homeassistant.const import ATTR_IDENTIFIERS, ATTR_VIA_DEVICE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -60,7 +61,12 @@ class LutronDevice(LutronBaseEntity):
"""Representation of a Lutron device entity."""
def __init__(
- self, area_name: str, lutron_device: LutronEntity, controller: Lutron
+ self,
+ hass: HomeAssistant,
+ area_name: str,
+ lutron_device: LutronEntity,
+ controller: Lutron,
+ config_entry_id: str,
) -> None:
"""Initialize the device."""
super().__init__(area_name, lutron_device, controller)
@@ -69,7 +75,9 @@ class LutronDevice(LutronBaseEntity):
manufacturer="Lutron",
name=lutron_device.name,
suggested_area=area_name,
- via_device=(DOMAIN, controller.guid),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, controller.guid), config_entry_id=config_entry_id
+ ),
)
@@ -78,20 +86,33 @@ class LutronKeypad(LutronBaseEntity):
def __init__(
self,
+ hass: HomeAssistant,
area_name: str,
lutron_device: LutronEntity,
controller: Lutron,
keypad: Keypad,
+ config_entry_id: str,
) -> None:
"""Initialize the device."""
super().__init__(area_name, lutron_device, controller)
+ self._keypad = keypad
device_uuid = keypad.uuid or keypad.legacy_uuid
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, f"{controller.guid}_{device_uuid}")},
- manufacturer="Lutron",
- name=keypad.name,
- )
+ identifiers = {(DOMAIN, f"{controller.guid}_{device_uuid}")}
+ # The main repeater keypad is the controller device itself, so it owns
+ # the controller identifier instead of linking to it via a via device.
if keypad.type == "MAIN_REPEATER":
- self._attr_device_info[ATTR_IDENTIFIERS].add((DOMAIN, controller.guid))
+ identifiers.add((DOMAIN, controller.guid))
+ self._attr_device_info = DeviceInfo(
+ identifiers=identifiers,
+ manufacturer="Lutron",
+ name=keypad.name,
+ )
else:
- self._attr_device_info[ATTR_VIA_DEVICE] = (DOMAIN, controller.guid)
+ self._attr_device_info = DeviceInfo(
+ identifiers=identifiers,
+ manufacturer="Lutron",
+ name=keypad.name,
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, controller.guid), config_entry_id=config_entry_id
+ ),
+ )
diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py
index e7e86799507d..b9b086eb0bd2 100644
--- a/homeassistant/components/lutron/event.py
+++ b/homeassistant/components/lutron/event.py
@@ -39,7 +39,9 @@ async def async_setup_entry(
entry_data = config_entry.runtime_data
async_add_entities(
- LutronEventEntity(area_name, keypad, button, entry_data.client)
+ LutronEventEntity(
+ hass, area_name, keypad, button, entry_data.client, config_entry.entry_id
+ )
for area_name, keypad, button in entry_data.buttons
)
@@ -51,13 +53,15 @@ class LutronEventEntity(LutronKeypad, EventEntity):
def __init__(
self,
+ hass: HomeAssistant,
area_name: str,
keypad: Keypad,
button: Button,
controller: Lutron,
+ config_entry_id: str,
) -> None:
"""Initialize the button."""
- super().__init__(area_name, button, controller, keypad)
+ super().__init__(hass, area_name, button, controller, keypad, config_entry_id)
if (name := button.name) == "Unknown Button":
name += f" {button.number}"
self._attr_name = name
diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py
index 5c7c41f4b3e8..057a74e03780 100644
--- a/homeassistant/components/lutron/fan.py
+++ b/homeassistant/components/lutron/fan.py
@@ -25,7 +25,7 @@ async def async_setup_entry(
entry_data = config_entry.runtime_data
async_add_entities(
[
- LutronFan(area_name, device, entry_data.client)
+ LutronFan(hass, area_name, device, entry_data.client, config_entry.entry_id)
for area_name, device in entry_data.fans
],
True,
diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py
index 9b708815e70d..49681392331c 100644
--- a/homeassistant/components/lutron/light.py
+++ b/homeassistant/components/lutron/light.py
@@ -36,7 +36,7 @@ async def async_setup_entry(
async_add_entities(
(
- LutronLight(area_name, device, entry_data.client, config_entry)
+ LutronLight(hass, area_name, device, entry_data.client, config_entry)
for area_name, device in entry_data.lights
),
True,
@@ -65,13 +65,16 @@ class LutronLight(LutronDevice, LightEntity):
def __init__(
self,
+ hass: HomeAssistant,
area_name: str,
lutron_device: LutronEntity,
controller: Lutron,
config_entry: ConfigEntry,
) -> None:
"""Initialize the device."""
- super().__init__(area_name, lutron_device, controller)
+ super().__init__(
+ hass, area_name, lutron_device, controller, config_entry.entry_id
+ )
self._config_entry = config_entry
@override
diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py
index 46b1b8f90495..c0797c0e1e66 100644
--- a/homeassistant/components/lutron/scene.py
+++ b/homeassistant/components/lutron/scene.py
@@ -25,7 +25,9 @@ async def async_setup_entry(
entry_data = config_entry.runtime_data
async_add_entities(
- LutronScene(area_name, keypad, device, entry_data.client)
+ LutronScene(
+ hass, area_name, keypad, device, entry_data.client, config_entry.entry_id
+ )
for area_name, keypad, device, led in entry_data.scenes
)
@@ -37,13 +39,17 @@ class LutronScene(LutronKeypad, Scene):
def __init__(
self,
+ hass: HomeAssistant,
area_name: str,
keypad: Keypad,
lutron_device: Button,
controller: Lutron,
+ config_entry_id: str,
) -> None:
"""Initialize the scene/button."""
- super().__init__(area_name, lutron_device, controller, keypad)
+ super().__init__(
+ hass, area_name, lutron_device, controller, keypad, config_entry_id
+ )
self._attr_name = lutron_device.name
@override
diff --git a/homeassistant/components/lutron/select.py b/homeassistant/components/lutron/select.py
index e70de30fcbb9..be448b42a2af 100644
--- a/homeassistant/components/lutron/select.py
+++ b/homeassistant/components/lutron/select.py
@@ -32,7 +32,15 @@ async def async_setup_entry(
# Add the indicator LEDs for scenes (keypad buttons)
async_add_entities(
[
- LutronLedSelect(area_name, keypad, scene, led, entry_data.client)
+ LutronLedSelect(
+ hass,
+ area_name,
+ keypad,
+ scene,
+ led,
+ entry_data.client,
+ config_entry.entry_id,
+ )
for area_name, keypad, scene, led in entry_data.scenes
if led is not None
],
@@ -49,14 +57,18 @@ class LutronLedSelect(LutronKeypad, SelectEntity):
def __init__(
self,
+ hass: HomeAssistant,
area_name: str,
keypad: Keypad,
scene_device: Button,
led_device: Led,
controller: Lutron,
+ config_entry_id: str,
) -> None:
"""Initialize the select entity."""
- super().__init__(area_name, led_device, controller, keypad)
+ super().__init__(
+ hass, area_name, led_device, controller, keypad, config_entry_id
+ )
self._attr_name = f"{scene_device.name} LED"
@property
diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py
index 245270628b5a..0feaaed64cf6 100644
--- a/homeassistant/components/lutron/switch.py
+++ b/homeassistant/components/lutron/switch.py
@@ -28,12 +28,26 @@ async def async_setup_entry(
# Add Lutron Switches
for area_name, device in entry_data.switches:
- entities.append(LutronSwitch(area_name, device, entry_data.client))
+ entities.append(
+ LutronSwitch(
+ hass, area_name, device, entry_data.client, config_entry.entry_id
+ )
+ )
# Add the indicator LEDs for scenes (keypad buttons)
for area_name, keypad, scene, led in entry_data.scenes:
if led is not None:
- entities.append(LutronLed(area_name, keypad, scene, led, entry_data.client))
+ entities.append(
+ LutronLed(
+ hass,
+ area_name,
+ keypad,
+ scene,
+ led,
+ entry_data.client,
+ config_entry.entry_id,
+ )
+ )
async_add_entities(entities, True)
@@ -77,14 +91,18 @@ class LutronLed(LutronKeypad, SwitchEntity):
def __init__(
self,
+ hass: HomeAssistant,
area_name: str,
keypad: Keypad,
scene_device: Button,
led_device: Led,
controller: Lutron,
+ config_entry_id: str,
) -> None:
"""Initialize the switch."""
- super().__init__(area_name, led_device, controller, keypad)
+ super().__init__(
+ hass, area_name, led_device, controller, keypad, config_entry_id
+ )
self._keypad_name = keypad.name
self._attr_name = scene_device.name
diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py
index 0ee597d0ec68..2ad7a7c17264 100644
--- a/homeassistant/components/lutron_caseta/__init__.py
+++ b/homeassistant/components/lutron_caseta/__init__.py
@@ -217,7 +217,7 @@ async def async_setup_entry(
# Store this bridge (keyed by entry_id) so it can be retrieved by the
# platforms we're setting up.
- entry.runtime_data = LutronCasetaData(bridge, bridge_device, keypad_data)
+ entry.runtime_data = LutronCasetaData(bridge, bridge_device, keypad_data, entry_id)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -236,7 +236,6 @@ def _async_register_bridge_device(
manufacturer=MANUFACTURER,
identifiers={(DOMAIN, bridge_device["serial"])},
model=f"{bridge_device['model']} ({bridge_device['type']})",
- via_device=(DOMAIN, bridge_device["serial"]),
configuration_url="https://device-login.lutron.com",
)
@@ -244,7 +243,15 @@ def _async_register_bridge_device(
if area != UNASSIGNED_AREA:
device_args["suggested_area"] = area
- device_registry.async_get_or_create(**device_args, config_entry_id=config_entry_id)
+ device = device_registry.async_get_or_create(
+ **device_args, config_entry_id=config_entry_id
+ )
+ if device.via_device_id is not None:
+ # Existing installations may still have the bridge device linked to
+ # itself via via_device_id, from when it was (incorrectly) registered
+ # as its own via device. Clear it explicitly since async_get_or_create
+ # above leaves via_device_id untouched when it's not passed.
+ device_registry.async_update_device(device.id, via_device_id=None)
@callback
@@ -280,7 +287,12 @@ def _async_setup_keypads(
if not (keypad := keypads.get(keypad_lutron_device_id)):
# First time seeing this keypad, build keypad data and store in keypads
keypad = keypads[keypad_lutron_device_id] = _async_build_lutron_keypad(
- bridge, bridge_device, bridge_keypad, keypad_lutron_device_id
+ hass,
+ config_entry_id,
+ bridge,
+ bridge_device,
+ bridge_keypad,
+ keypad_lutron_device_id,
)
# Register the keypad device
@@ -353,6 +365,8 @@ def _async_build_trigger_schemas(
@callback
def _async_build_lutron_keypad(
+ hass: HomeAssistant,
+ config_entry_id: str,
bridge: Smartbridge,
bridge_device: dict[str, Any],
bridge_keypad: dict[str, Any],
@@ -367,7 +381,9 @@ def _async_build_lutron_keypad(
manufacturer=MANUFACTURER,
identifiers={(DOMAIN, keypad_serial)},
model=f"{bridge_keypad['model']} ({bridge_keypad['type']})",
- via_device=(DOMAIN, bridge_device["serial"]),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, bridge_device["serial"]), config_entry_id=config_entry_id
+ ),
)
if area_name != UNASSIGNED_AREA:
device_info["suggested_area"] = area_name
diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py
index c137137aa9bf..397f1c2023eb 100644
--- a/homeassistant/components/lutron_caseta/binary_sensor.py
+++ b/homeassistant/components/lutron_caseta/binary_sensor.py
@@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.const import ATTR_SUGGESTED_AREA, EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -40,12 +41,12 @@ async def async_setup_entry(
bridge = data.bridge
occupancy_groups = bridge.occupancy_groups
async_add_entities(
- LutronOccupancySensor(occupancy_group, data)
+ LutronOccupancySensor(hass, occupancy_group, data)
for occupancy_group in occupancy_groups.values()
)
async_add_entities(
(
- LutronCasetaBatterySensor(device, data)
+ LutronCasetaBatterySensor(hass, device, data)
for device in bridge.get_devices_by_domain(COVER_DOMAIN)
),
update_before_add=True,
@@ -57,9 +58,11 @@ class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
- def __init__(self, device, data):
+ def __init__(
+ self, hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData
+ ) -> None:
"""Init an occupancy sensor."""
- super().__init__(device, data)
+ super().__init__(hass, device, data)
area = area_name_from_id(self._smartbridge.areas, device["area"])
name = f"{area} {device['device_name']}"
self._attr_name = name
@@ -67,8 +70,12 @@ class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity):
identifiers={(DOMAIN, self.unique_id)},
manufacturer=MANUFACTURER,
model="Lutron Occupancy",
- name=self.name,
- via_device=(DOMAIN, self._bridge_device["serial"]),
+ name=name,
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, self._bridge_device["serial"]),
+ config_entry_id=data.config_entry_id,
+ ),
configuration_url=CONFIG_URL,
entry_type=DeviceEntryType.SERVICE,
)
@@ -116,9 +123,11 @@ class LutronCasetaBatterySensor(LutronCasetaEntity, BinarySensorEntity):
_attr_has_entity_name = True
_attr_should_poll = True
- def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData
+ ) -> None:
"""Initialize the battery sensor."""
- super().__init__(device, data)
+ super().__init__(hass, device, data)
# The base entity sets the shade name; remove it so the battery device
# class provides the sensor name.
if hasattr(self, "_attr_name"):
diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py
index 934a97bb4c97..781152315fb9 100644
--- a/homeassistant/components/lutron_caseta/button.py
+++ b/homeassistant/components/lutron_caseta/button.py
@@ -56,7 +56,7 @@ async def async_setup_entry(
# The entities will be nested inside the keypad device
entities.append(
LutronCasetaButton(
- device, data, full_name, enabled_default, parent_device_info
+ hass, device, data, full_name, enabled_default, parent_device_info
),
)
@@ -68,6 +68,7 @@ class LutronCasetaButton(LutronCasetaEntity, ButtonEntity):
def __init__(
self,
+ hass: HomeAssistant,
device: dict[str, Any],
data: LutronCasetaData,
full_name: str,
@@ -75,7 +76,7 @@ class LutronCasetaButton(LutronCasetaEntity, ButtonEntity):
device_info: DeviceInfo,
) -> None:
"""Init a button entity."""
- super().__init__(device, data)
+ super().__init__(hass, device, data)
self._attr_entity_registry_enabled_default = enabled_default
self._attr_name = full_name
self._attr_device_info = device_info
diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py
index 96a2c616e10a..2c689ee67a66 100644
--- a/homeassistant/components/lutron_caseta/cover.py
+++ b/homeassistant/components/lutron_caseta/cover.py
@@ -183,7 +183,7 @@ async def async_setup_entry(
# default to standard LutronCasetaCover type if the
# pylutron type is not yet mapped
PYLUTRON_TYPE_TO_CLASSES.get(cover_device["type"], LutronCasetaShade)(
- cover_device, data
+ hass, cover_device, data
)
for cover_device in cover_devices
)
diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py
index 0380032bbd83..09c9bb6efcbf 100644
--- a/homeassistant/components/lutron_caseta/entity.py
+++ b/homeassistant/components/lutron_caseta/entity.py
@@ -4,6 +4,8 @@ import logging
from typing import Any, override
from homeassistant.const import ATTR_SUGGESTED_AREA
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -19,7 +21,9 @@ class LutronCasetaEntity(Entity):
_attr_should_poll = False
- def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData
+ ) -> None:
"""Set up the base class.
[:param]device the device metadata
@@ -54,7 +58,11 @@ class LutronCasetaEntity(Entity):
manufacturer=MANUFACTURER,
model=f"{device['model']} ({device['type']})",
name=full_name,
- via_device=(DOMAIN, self._bridge_device["serial"]),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, self._bridge_device["serial"]),
+ config_entry_id=data.config_entry_id,
+ ),
configuration_url=CONFIG_URL,
)
if area != UNASSIGNED_AREA:
diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py
index 9f2272e0605a..eb224c357481 100644
--- a/homeassistant/components/lutron_caseta/fan.py
+++ b/homeassistant/components/lutron_caseta/fan.py
@@ -36,7 +36,9 @@ async def async_setup_entry(
data = config_entry.runtime_data
bridge = data.bridge
fan_devices = bridge.get_devices_by_domain(FAN_DOMAIN)
- async_add_entities(LutronCasetaFan(fan_device, data) for fan_device in fan_devices)
+ async_add_entities(
+ LutronCasetaFan(hass, fan_device, data) for fan_device in fan_devices
+ )
class LutronCasetaFan(LutronCasetaUpdatableEntity, FanEntity):
diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py
index c5340c22abff..8a6451149d71 100644
--- a/homeassistant/components/lutron_caseta/light.py
+++ b/homeassistant/components/lutron_caseta/light.py
@@ -77,7 +77,7 @@ async def async_setup_entry(
bridge = data.bridge
light_devices = bridge.get_devices_by_domain(LIGHT_DOMAIN)
async_add_entities(
- LutronCasetaLight(light_device, data) for light_device in light_devices
+ LutronCasetaLight(hass, light_device, data) for light_device in light_devices
)
@@ -90,9 +90,11 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity):
_attr_supported_features = LightEntityFeature.TRANSITION
_prev_brightness: int | None = None
- def __init__(self, light: dict[str, Any], data: LutronCasetaData) -> None:
+ def __init__(
+ self, hass: HomeAssistant, light: dict[str, Any], data: LutronCasetaData
+ ) -> None:
"""Initialize the light and set the supported color modes."""
- super().__init__(light, data)
+ super().__init__(hass, light, data)
self._attr_min_color_temp_kelvin = self._get_min_color_temp_kelvin(light)
self._attr_max_color_temp_kelvin = self._get_max_color_temp_kelvin(light)
diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py
index 73f399ad8fa4..722f1c0bf016 100644
--- a/homeassistant/components/lutron_caseta/models.py
+++ b/homeassistant/components/lutron_caseta/models.py
@@ -19,6 +19,7 @@ class LutronCasetaData:
bridge: Smartbridge
bridge_device: dict[str, Any]
keypad_data: LutronKeypadData
+ config_entry_id: str
@dataclass
diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py
index edad464fc741..c110c95ad3a5 100644
--- a/homeassistant/components/lutron_caseta/switch.py
+++ b/homeassistant/components/lutron_caseta/switch.py
@@ -27,11 +27,11 @@ async def async_setup_entry(
bridge = data.bridge
switch_devices = bridge.get_devices_by_domain(SWITCH_DOMAIN)
entities: list[LutronCasetaLight | LutronCasetaSmartAwaySwitch] = [
- LutronCasetaLight(switch_device, data) for switch_device in switch_devices
+ LutronCasetaLight(hass, switch_device, data) for switch_device in switch_devices
]
if bridge.smart_away_state != "":
- entities.append(LutronCasetaSmartAwaySwitch(data))
+ entities.append(LutronCasetaSmartAwaySwitch(hass, data))
async_add_entities(entities)
@@ -39,10 +39,12 @@ async def async_setup_entry(
class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
"""Representation of a Lutron Caseta switch."""
- def __init__(self, device, data):
+ def __init__(
+ self, hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData
+ ) -> None:
"""Init a button entity."""
- super().__init__(device, data)
+ super().__init__(hass, device, data)
self._enabled_default = True
if "parent_device" not in device:
@@ -78,7 +80,7 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
class LutronCasetaSmartAwaySwitch(LutronCasetaEntity, SwitchEntity):
"""Representation of Lutron Caseta Smart Away."""
- def __init__(self, data: LutronCasetaData) -> None:
+ def __init__(self, hass: HomeAssistant, data: LutronCasetaData) -> None:
"""Init a switch entity."""
device = {
"device_id": "smart_away",
@@ -88,7 +90,7 @@ class LutronCasetaSmartAwaySwitch(LutronCasetaEntity, SwitchEntity):
"area": data.bridge_device["area"],
"serial": data.bridge_device["serial"],
}
- super().__init__(device, data)
+ super().__init__(hass, device, data)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.bridge_device["serial"])},
)
diff --git a/homeassistant/components/lyngdorf/__init__.py b/homeassistant/components/lyngdorf/__init__.py
index 56d8b3db398e..3cb1a64188eb 100644
--- a/homeassistant/components/lyngdorf/__init__.py
+++ b/homeassistant/components/lyngdorf/__init__.py
@@ -7,9 +7,11 @@ 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 import device_registry as dr
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
+ async_get_device_id_by_identifier,
format_mac,
)
@@ -69,6 +71,12 @@ async def async_setup_entry(
model=lyngdorf_model.model_name,
)
+ # Register the main device up front so Zone B can resolve its via_device_id.
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id, **device_info
+ )
+
zone_b_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{config_entry.unique_id}_zone_b")},
manufacturer=lyngdorf_model.manufacturer,
@@ -76,7 +84,11 @@ async def async_setup_entry(
model=lyngdorf_model.model_name,
translation_key="zone_b",
translation_placeholders={"device_name": config_entry.title},
- via_device=(DOMAIN, config_entry.unique_id),
+ via_device_id=async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, config_entry.unique_id),
+ config_entry_id=config_entry.entry_id,
+ ),
)
config_entry.runtime_data = LyngdorfRuntimeData(
diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py
index af610f127372..54c843ae11f4 100644
--- a/homeassistant/components/lyric/__init__.py
+++ b/homeassistant/components/lyric/__init__.py
@@ -9,6 +9,7 @@ from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
config_validation as cv,
+ device_registry as dr,
)
from .api import (
@@ -18,6 +19,7 @@ from .api import (
)
from .const import DOMAIN
from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
+from .entity import create_thermostat_device_info
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -59,6 +61,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> boo
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
+ # Register the thermostat devices up front so the accessory (room sensor)
+ # entities can resolve them as their via_device parent regardless of the
+ # order the platforms are set up in.
+ device_registry = dr.async_get(hass)
+ for location in coordinator.data.locations:
+ for device in location.devices:
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **create_thermostat_device_info(device),
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/lyric/entity.py b/homeassistant/components/lyric/entity.py
index 151de2c8d539..588fdbffa3eb 100644
--- a/homeassistant/components/lyric/entity.py
+++ b/homeassistant/components/lyric/entity.py
@@ -50,6 +50,17 @@ class LyricEntity(CoordinatorEntity[LyricDataUpdateCoordinator]):
return self.location.devices_dict[self._mac_id]
+def create_thermostat_device_info(device: LyricDevice) -> DeviceInfo:
+ """Return the device info for a Lyric thermostat."""
+ return DeviceInfo(
+ identifiers={(dr.CONNECTION_NETWORK_MAC, device.mac_id)},
+ connections={(dr.CONNECTION_NETWORK_MAC, device.mac_id)},
+ manufacturer="Honeywell",
+ model=device.device_model,
+ name=f"{device.name} Thermostat",
+ )
+
+
class LyricDeviceEntity(LyricEntity):
"""Defines a Honeywell Lyric device entity."""
@@ -57,13 +68,7 @@ class LyricDeviceEntity(LyricEntity):
@override
def device_info(self) -> DeviceInfo:
"""Return device information about this Honeywell Lyric instance."""
- return DeviceInfo(
- identifiers={(dr.CONNECTION_NETWORK_MAC, self._mac_id)},
- connections={(dr.CONNECTION_NETWORK_MAC, self._mac_id)},
- manufacturer="Honeywell",
- model=self.device.device_model,
- name=f"{self.device.name} Thermostat",
- )
+ return create_thermostat_device_info(self.device)
class LyricAccessoryEntity(LyricDeviceEntity):
@@ -97,7 +102,11 @@ class LyricAccessoryEntity(LyricDeviceEntity):
manufacturer="Honeywell",
model="RCHTSENSOR",
name=f"{self.room.room_name} Sensor",
- via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.hass,
+ (dr.CONNECTION_NETWORK_MAC, self._mac_id),
+ config_entry_id=self.coordinator.config_entry.entry_id,
+ ),
)
@property
diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py
index 4cc112509d99..312a28b562ae 100644
--- a/homeassistant/components/melnor/__init__.py
+++ b/homeassistant/components/melnor/__init__.py
@@ -7,7 +7,10 @@ from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
from homeassistant.const import CONF_ADDRESS, Platform
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_BLUETOOTH
+from .const import DOMAIN
from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -54,6 +57,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelnorConfigEntry) -> bo
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
+
+ # Register the parent device up front so zone entities can deterministically
+ # resolve its via_device_id, regardless of platform setup order.
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, device.mac)},
+ connections={(CONNECTION_BLUETOOTH, device.mac)},
+ manufacturer="Melnor",
+ model=device.model,
+ name=device.name,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/melnor/entity.py b/homeassistant/components/melnor/entity.py
index 35e804111f48..ac85df205c85 100644
--- a/homeassistant/components/melnor/entity.py
+++ b/homeassistant/components/melnor/entity.py
@@ -7,6 +7,7 @@ from melnor_bluetooth.device import Device, Valve
from homeassistant.components.number import EntityDescription
from homeassistant.core import callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -76,7 +77,11 @@ class MelnorZoneEntity(MelnorBluetoothEntity):
identifiers={(DOMAIN, f"{self._device.mac}-zone{self._valve.id}")},
manufacturer="Melnor",
name=f"Zone {valve.id + 1}",
- via_device=(DOMAIN, self._device.mac),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, self._device.mac),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json
index a089db203cff..3e374d45e7c8 100644
--- a/homeassistant/components/midea/manifest.json
+++ b/homeassistant/components/midea/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "midea",
"name": "Midea",
- "codeowners": ["@chemelli74", "@rokam", "@wuwentao"],
+ "codeowners": ["@chemelli74", "@rokam", "@wuwentao", "@caibinqing"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/midea",
"integration_type": "device",
diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py
index 60fbd0dda320..463dd083c435 100644
--- a/homeassistant/components/mikrotik/__init__.py
+++ b/homeassistant/components/mikrotik/__init__.py
@@ -17,9 +17,11 @@ from .coordinator import (
)
PLATFORMS = [
+ Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.SENSOR,
+ Platform.SWITCH,
Platform.UPDATE,
]
@@ -43,8 +45,6 @@ async def async_setup_entry(
config_entry.runtime_data = coordinator
- await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
-
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
@@ -55,6 +55,8 @@ async def async_setup_entry(
sw_version=coordinator.firmware,
)
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+
return True
diff --git a/homeassistant/components/mikrotik/binary_sensor.py b/homeassistant/components/mikrotik/binary_sensor.py
new file mode 100644
index 000000000000..5b594a127304
--- /dev/null
+++ b/homeassistant/components/mikrotik/binary_sensor.py
@@ -0,0 +1,56 @@
+"""Support for binary sensors."""
+
+from typing import Final, 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 .coordinator import MikrotikConfigEntry
+from .entity import MikrotikDeviceEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+BINARY_SENSOR_TYPES: Final = {
+ BinarySensorEntityDescription(
+ key="running",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: MikrotikConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up binary sensor entities for Mikrotik Devices."""
+
+ coordinator = entry.runtime_data
+
+ binary_sensors_list = [
+ MikrotikBinarySensorEntity(entry, coordinator, binary_sensor_desc, interface)
+ for binary_sensor_desc in BINARY_SENSOR_TYPES
+ for interface in coordinator.api.interfaces
+ if interface.get(binary_sensor_desc.key) is not None
+ ]
+
+ async_add_entities(binary_sensors_list)
+
+
+class MikrotikBinarySensorEntity(MikrotikDeviceEntity, BinarySensorEntity):
+ """Binary sensor entity for Mikrotik."""
+
+ @property
+ @override
+ def is_on(self) -> bool | None:
+ """Return the state of the binary sensor."""
+ return self._interface.get(self.entity_description.key)
diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py
index 310578b739af..9cd9a587a232 100644
--- a/homeassistant/components/mikrotik/const.py
+++ b/homeassistant/components/mikrotik/const.py
@@ -25,10 +25,12 @@ DHCP: Final = "dhcp"
FIRMWARE_UPDATE: Final = "fw-update"
HEALTH: Final = "health"
IDENTITY: Final = "identity"
+INTERFACE: Final = "interface"
IS_CAPSMAN: Final = "is_capsman"
IS_WIFI: Final = "is_wifi"
IS_WIFIWAVE2: Final = "is_wifiwave2"
IS_WIRELESS: Final = "is_wireless"
+POE: Final = "poe"
REBOOT: Final = "reboot"
RESOURCE: Final = "resource"
ROUTERBOARD_UPDATE: Final = "routerboard-update"
@@ -48,10 +50,12 @@ MIKROTIK_SERVICES: Final = {
FIRMWARE_UPDATE: "/system/package/update/install",
HEALTH: "/system/health/print",
IDENTITY: "/system/identity/getall",
+ INTERFACE: "/interface/print",
IS_CAPSMAN: "/caps-man/interface/print",
IS_WIFI: "/interface/wifi/print",
IS_WIFIWAVE2: "/interface/wifiwave2/print",
IS_WIRELESS: "/interface/wireless/print",
+ POE: "/interface/ethernet/poe/print",
REBOOT: "/system/reboot",
RESOURCE: "/system/resource/print",
ROUTERBOARD_UPDATE: "/system/routerboard/upgrade",
diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py
index 347f4d655224..97e2c0aa388a 100644
--- a/homeassistant/components/mikrotik/coordinator.py
+++ b/homeassistant/components/mikrotik/coordinator.py
@@ -32,12 +32,14 @@ from .const import (
DOMAIN,
HEALTH,
IDENTITY,
+ INTERFACE,
IS_CAPSMAN,
IS_WIFI,
IS_WIFIWAVE2,
IS_WIRELESS,
MIKROTIK_SERVICES,
NAME,
+ POE,
RESOURCE,
ROUTERBOARD,
UPDATE,
@@ -77,6 +79,7 @@ class MikrotikData:
self.serial_number: str = ""
self.sensors: dict[str, Any] = {}
self.system: dict[str, Any] = {}
+ self.interfaces: list[dict[str, Any]] = []
def _get_system_details(self, during_setup: bool = False) -> None:
"""Retrieve system and routerboard details from Mikrotik API."""
@@ -92,6 +95,43 @@ class MikrotikData:
or [{}]
)[0]
+ def _get_interfaces_details(self) -> None:
+ """Get interfaces details."""
+ all_interfs = self.command(MIKROTIK_SERVICES[INTERFACE])
+
+ fields = {
+ ".id",
+ "name",
+ "type",
+ "mac-address",
+ "running",
+ "disabled",
+ }
+
+ poe_interfs = self.command(MIKROTIK_SERVICES[POE], suppress_errors=True) or []
+
+ poe_by_id = {
+ poe_interf[".id"]: poe_interf.get("poe-out", "off")
+ for poe_interf in poe_interfs
+ }
+
+ existing_by_id = {interf[".id"]: interf for interf in self.interfaces}
+
+ interfaces = []
+ for interf in all_interfs:
+ if interf.get("type") == "loopback":
+ continue
+ data = {key: interf.get(key) for key in fields}
+ if (poe_out := poe_by_id.get(data[".id"])) is not None:
+ data["poe-out"] = poe_out
+ if existing := existing_by_id.get(data[".id"]):
+ existing.update(data)
+ interfaces.append(existing)
+ else:
+ interfaces.append(data)
+
+ self.interfaces = interfaces
+
@staticmethod
def load_mac(devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Load dictionary using MAC address as key."""
@@ -198,6 +238,8 @@ class MikrotikData:
self.command(MIKROTIK_SERVICES[RESOURCE], suppress_errors=True) or []
)
+ self._get_interfaces_details()
+
if not device_list:
return
diff --git a/homeassistant/components/mikrotik/entity.py b/homeassistant/components/mikrotik/entity.py
index 1f9041a7accd..095dd01a8e84 100644
--- a/homeassistant/components/mikrotik/entity.py
+++ b/homeassistant/components/mikrotik/entity.py
@@ -2,13 +2,13 @@
from yarl import URL
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
-from .coordinator import MikrotikDataUpdateCoordinator
+from .coordinator import MikrotikConfigEntry, MikrotikDataUpdateCoordinator
class MikrotikBaseEntity(CoordinatorEntity[MikrotikDataUpdateCoordinator]):
@@ -27,10 +27,10 @@ class MikrotikBaseEntity(CoordinatorEntity[MikrotikDataUpdateCoordinator]):
self._serial = coordinator.api.serial_number
- def _base_device_info(self) -> DeviceInfo:
+ def _base_device_info(self) -> dr.DeviceInfo:
"""Return the device info fields shared by all Mikrotik devices."""
coordinator = self.coordinator
- return DeviceInfo(
+ return dr.DeviceInfo(
configuration_url=URL.build(
scheme="http",
host=coordinator.host,
@@ -52,7 +52,7 @@ class MikrotikEntity(MikrotikBaseEntity):
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, description)
- self._attr_device_info = DeviceInfo(
+ self._attr_device_info = dr.DeviceInfo(
**self._base_device_info(),
identifiers={(DOMAIN, self._serial)},
name=coordinator.hostname,
@@ -65,6 +65,7 @@ class MikrotikDeviceEntity(MikrotikBaseEntity):
def __init__(
self,
+ config_entry: MikrotikConfigEntry,
coordinator: MikrotikDataUpdateCoordinator,
description: EntityDescription,
interface: dict,
@@ -75,12 +76,15 @@ class MikrotikDeviceEntity(MikrotikBaseEntity):
name = interface.get("name")
ident = f"{slugify(interface.get('mac-address'))}_{name}"
- self._attr_device_info = DeviceInfo(
+ self._attr_device_info = dr.DeviceInfo(
**self._base_device_info(),
identifiers={(DOMAIN, ident)},
name=name,
- via_device=(DOMAIN, coordinator.api.serial_number),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ config_entry.runtime_data.hass,
+ (DOMAIN, coordinator.api.serial_number),
+ config_entry_id=config_entry.entry_id,
+ ),
)
self._attr_unique_id = ident
- self._attr_name = name
self._interface = interface
diff --git a/homeassistant/components/mikrotik/icons.json b/homeassistant/components/mikrotik/icons.json
index 39724f156b95..0a416c4a37b4 100644
--- a/homeassistant/components/mikrotik/icons.json
+++ b/homeassistant/components/mikrotik/icons.json
@@ -18,6 +18,14 @@
"memory_usage": {
"default": "mdi:memory"
}
+ },
+ "switch": {
+ "ether": {
+ "default": "mdi:lan"
+ },
+ "wlan": {
+ "default": "mdi:wifi"
+ }
}
}
}
diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json
index 3e0bee9422b8..25137d88d3ea 100644
--- a/homeassistant/components/mikrotik/strings.json
+++ b/homeassistant/components/mikrotik/strings.json
@@ -61,6 +61,14 @@
"name": "Memory usage"
}
},
+ "switch": {
+ "ether": {
+ "name": "Ethernet"
+ },
+ "wlan": {
+ "name": "WLAN"
+ }
+ },
"update": {
"firmware_update": {
"name": "RouterOS"
diff --git a/homeassistant/components/mikrotik/switch.py b/homeassistant/components/mikrotik/switch.py
new file mode 100644
index 000000000000..44ad66a92e3c
--- /dev/null
+++ b/homeassistant/components/mikrotik/switch.py
@@ -0,0 +1,83 @@
+"""Support for Mikrotik routers switches."""
+
+from typing import Any, Final, override
+
+from homeassistant.components.switch import (
+ SwitchDeviceClass,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import MikrotikConfigEntry, mikrotik_config_entry_errors
+from .entity import MikrotikDeviceEntity
+
+PARALLEL_UPDATES = 0
+
+
+SENSORS: Final = (
+ SwitchEntityDescription(
+ key="ether",
+ translation_key="ether",
+ device_class=SwitchDeviceClass.SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key="wlan",
+ translation_key="wlan",
+ device_class=SwitchDeviceClass.SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: MikrotikConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Mikrotik switch based on a config entry."""
+
+ coordinator = entry.runtime_data
+
+ switch_list = [
+ MikrotikSwitchEntity(entry, coordinator, switch_desc, interface)
+ for switch_desc in SENSORS
+ for interface in coordinator.api.interfaces
+ if interface.get("type") == switch_desc.key
+ ]
+
+ async_add_entities(switch_list)
+
+
+class MikrotikSwitchEntity(MikrotikDeviceEntity, SwitchEntity):
+ """Switch device."""
+
+ @property
+ @override
+ def is_on(self) -> bool | None:
+ """Return the state of the switch."""
+ return not self._interface.get("disabled")
+
+ async def _set_state(self, action: str) -> None:
+ """Toggle the state of the switch."""
+ with mikrotik_config_entry_errors():
+ await self.hass.async_add_executor_job(
+ self.coordinator.api.command,
+ f"/interface/{action}",
+ {".id": self._interface[".id"]},
+ )
+ self._interface["disabled"] = action == "disable"
+ self.async_write_ha_state()
+
+ @override
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ await self._set_state("enable")
+
+ @override
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ await self._set_state("disable")
diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py
index db35e8351f0c..557301ea9f98 100644
--- a/homeassistant/components/mqtt/util.py
+++ b/homeassistant/components/mqtt/util.py
@@ -443,12 +443,13 @@ async def async_cleanup_device_registry(
device_id
and device_id not in device_registry.deleted_devices
and config_entry_id
+ and (device := device_registry.async_get(device_id)) is not None
+ # Only remove the device if it is owned by the MQTT config entry
+ and device.config_entry_id == config_entry_id
and not er.async_entries_for_device(
entity_registry, device_id, include_disabled_entities=False
)
and not await device_trigger.async_get_triggers(hass, device_id)
and not tag.async_has_tags(hass, device_id)
):
- device_registry.async_update_device(
- device_id, remove_config_entry_id=config_entry_id
- )
+ device_registry.async_remove_device(device_id)
diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py
index d856e3cd7379..788d05f97454 100644
--- a/homeassistant/components/music_assistant/__init__.py
+++ b/homeassistant/components/music_assistant/__init__.py
@@ -183,9 +183,7 @@ async def async_setup_entry( # noqa: C901
if hass_device := dev_reg.async_get_device_by_identifier(
(DOMAIN, player_id), entry.entry_id
):
- dev_reg.async_update_device(
- hass_device.id, remove_config_entry_id=entry.entry_id
- )
+ dev_reg.async_remove_device(hass_device.id)
# register listener for new players
def handle_player_added(event: MassEvent) -> None:
diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py
index 83c9a9966e03..40dc0ba20d71 100644
--- a/homeassistant/components/nobo_hub/climate.py
+++ b/homeassistant/components/nobo_hub/climate.py
@@ -29,7 +29,6 @@ from homeassistant.util import dt as dt_util
from . import NoboHubConfigEntry
from .const import (
- ATTR_SERIAL,
ATTR_TEMP_COMFORT_C,
ATTR_TEMP_ECO_C,
CONF_OVERRIDE_TYPE,
@@ -78,7 +77,8 @@ async def async_setup_entry(
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
+ NoboZone(hass, zone_id, hub, override_type, config_entry.entry_id)
+ for zone_id in new_zones
)
_add_zones(hub)
@@ -106,17 +106,25 @@ class NoboZone(NoboBaseEntity, ClimateEntity):
# Need to poll to get preset change when in HVACMode.AUTO
_attr_should_poll = True
- def __init__(self, zone_id: str, hub: nobo, override_type: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ zone_id: str,
+ hub: nobo,
+ override_type: str,
+ entry_id: str,
+ ) -> None:
"""Initialize the climate device."""
- super().__init__(hub)
+ super().__init__(hass, hub, entry_id)
self._id = zone_id
self._attr_unique_id = f"{hub.hub_serial}:{zone_id}"
self._override_type = override_type
+ zone_name = hub.zones[zone_id][ATTR_NAME]
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")},
- name=hub.zones[zone_id][ATTR_NAME],
- via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]),
- suggested_area=hub.zones[zone_id][ATTR_NAME],
+ identifiers={(DOMAIN, self._attr_unique_id)},
+ name=zone_name,
+ via_device_id=self._hub_device_id,
+ suggested_area=zone_name,
)
self._read_state()
diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py
index 7ad26edb40bf..4620b9c0596e 100644
--- a/homeassistant/components/nobo_hub/entity.py
+++ b/homeassistant/components/nobo_hub/entity.py
@@ -4,9 +4,12 @@ from typing import override
from pynobo import nobo
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity
+from .const import ATTR_SERIAL, DOMAIN
+
class NoboBaseEntity(Entity):
"""Base class for Nobø Ecohub entities."""
@@ -14,10 +17,13 @@ class NoboBaseEntity(Entity):
_attr_has_entity_name = True
_attr_should_poll = False
- def __init__(self, hub: nobo) -> None:
+ def __init__(self, hass: HomeAssistant, hub: nobo, entry_id: str) -> None:
"""Initialize the entity."""
self._nobo = hub
self._attr_available = hub.connected
+ self._hub_device_id = dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, hub.hub_info[ATTR_SERIAL]), config_entry_id=entry_id
+ )
@override
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py
index ef6f7f29f365..3473bf57b064 100644
--- a/homeassistant/components/nobo_hub/select.py
+++ b/homeassistant/components/nobo_hub/select.py
@@ -14,7 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NoboHubConfigEntry
from .const import (
ATTR_HARDWARE_VERSION,
- ATTR_SERIAL,
ATTR_SOFTWARE_VERSION,
CONF_OVERRIDE_TYPE,
DOMAIN,
@@ -40,7 +39,9 @@ async def async_setup_entry(
else nobo.API.OVERRIDE_TYPE_CONSTANT
)
- async_add_entities([NoboGlobalSelector(hub, override_type)], True)
+ async_add_entities(
+ [NoboGlobalSelector(hass, hub, override_type, config_entry.entry_id)], True
+ )
known_zones: set[str] = set()
@@ -56,7 +57,11 @@ async def async_setup_entry(
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
+ (
+ NoboProfileSelector(hass, zone_id, hub, config_entry.entry_id)
+ for zone_id in new_zones
+ ),
+ True,
)
_add_profiles(hub)
@@ -77,9 +82,11 @@ class NoboGlobalSelector(NoboBaseEntity, SelectEntity):
_attr_options = list(_modes.values())
_attr_current_option: str | None = None
- def __init__(self, hub: nobo, override_type: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, hub: nobo, override_type: str, entry_id: str
+ ) -> None:
"""Initialize the global override selector."""
- super().__init__(hub)
+ super().__init__(hass, hub, entry_id)
self._attr_unique_id = hub.hub_serial
self._override_type = override_type
self._attr_device_info = DeviceInfo(
@@ -126,18 +133,21 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity):
_attr_translation_key = "week_profile"
_attr_current_option: str | None = None
- def __init__(self, zone_id: str, hub: nobo) -> None:
+ def __init__(
+ self, hass: HomeAssistant, zone_id: str, hub: nobo, entry_id: str
+ ) -> None:
"""Initialize the week profile selector."""
- super().__init__(hub)
+ super().__init__(hass, hub, entry_id)
self._id = zone_id
self._profiles: dict[str, str] = {}
self._attr_options: list[str] = []
self._attr_unique_id = f"{hub.hub_serial}:{zone_id}:profile"
+ zone_name = hub.zones[zone_id][ATTR_NAME]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")},
- name=hub.zones[zone_id][ATTR_NAME],
- via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]),
- suggested_area=hub.zones[zone_id][ATTR_NAME],
+ name=zone_name,
+ via_device_id=self._hub_device_id,
+ suggested_area=zone_name,
)
@override
diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py
index 371fa96e6823..885c80b40f06 100644
--- a/homeassistant/components/nobo_hub/sensor.py
+++ b/homeassistant/components/nobo_hub/sensor.py
@@ -48,7 +48,8 @@ async def async_setup_entry(
]
known_components.update(new_components)
async_add_entities(
- NoboTemperatureSensor(serial, hub) for serial in new_components
+ NoboTemperatureSensor(hass, serial, hub, config_entry.entry_id)
+ for serial in new_components
)
_add_sensors(hub)
@@ -64,9 +65,11 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_suggested_display_precision = 1
- def __init__(self, serial: str, hub: nobo) -> None:
+ def __init__(
+ self, hass: HomeAssistant, serial: str, hub: nobo, entry_id: str
+ ) -> None:
"""Initialize the temperature sensor."""
- super().__init__(hub)
+ super().__init__(hass, hub, entry_id)
self._temperature: StateType = None
self._id = serial
component = hub.components[self._id]
@@ -81,7 +84,7 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity):
name=component[ATTR_NAME],
manufacturer=NOBO_MANUFACTURER,
model=component[ATTR_MODEL].name,
- via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]),
+ via_device_id=self._hub_device_id,
suggested_area=suggested_area,
)
self._read_state()
diff --git a/homeassistant/components/notion/entity.py b/homeassistant/components/notion/entity.py
index d8179aca51b8..0d1a9fdeee16 100644
--- a/homeassistant/components/notion/entity.py
+++ b/homeassistant/components/notion/entity.py
@@ -50,7 +50,13 @@ class NotionEntity(CoordinatorEntity[NotionDataUpdateCoordinator]):
)
if bridge := self._async_get_bridge(bridge_id):
- self._attr_device_info["via_device"] = (DOMAIN, bridge.hardware_id)
+ self._attr_device_info["via_device_id"] = (
+ dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, bridge.hardware_id),
+ config_entry_id=self.coordinator.config_entry.entry_id,
+ )
+ )
self._attr_extra_state_attributes = {}
self._attr_unique_id = listener_id
diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py
index 07cfe84fbed8..53ad39f91d9b 100644
--- a/homeassistant/components/ntfy/__init__.py
+++ b/homeassistant/components/ntfy/__init__.py
@@ -14,7 +14,7 @@ from aiontfy.update import UpdateChecker
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -92,6 +92,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool
entry.runtime_data = NtfyRuntimeData(coordinator, version)
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id, **coordinator.device_info
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
diff --git a/homeassistant/components/ntfy/coordinator.py b/homeassistant/components/ntfy/coordinator.py
index 3424d9ec938a..63b3c9f1df4a 100644
--- a/homeassistant/components/ntfy/coordinator.py
+++ b/homeassistant/components/ntfy/coordinator.py
@@ -15,10 +15,13 @@ from aiontfy.exceptions import (
NtfyUnauthorizedAuthenticationError,
)
from aiontfy.update import LatestRelease, UpdateChecker, UpdateCheckerError
+from yarl import URL
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -56,6 +59,17 @@ class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
self.ntfy = ntfy
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return device info of the ntfy account service device."""
+ return DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ manufacturer="ntfy LLC",
+ model="ntfy",
+ configuration_url=URL(self.config_entry.data[CONF_URL]) / "app",
+ identifiers={(DOMAIN, self.config_entry.entry_id)},
+ )
+
@abstractmethod
async def async_update_data(self) -> _DataT:
"""Fetch the latest data from the source."""
diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py
index 75e1ec1d943d..73cbaaabe7ba 100644
--- a/homeassistant/components/ntfy/entity.py
+++ b/homeassistant/components/ntfy/entity.py
@@ -4,6 +4,7 @@ from yarl import URL
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_URL
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -39,7 +40,11 @@ class NtfyBaseEntity(Entity):
name=subentry.title,
configuration_url=URL(config_entry.data[CONF_URL]) / self.topic,
identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")},
- via_device=(DOMAIN, config_entry.entry_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ config_entry.runtime_data.account.hass,
+ (DOMAIN, config_entry.entry_id),
+ config_entry_id=config_entry.entry_id,
+ ),
)
self.ntfy = config_entry.runtime_data.account.ntfy
self.config_entry = config_entry
@@ -60,10 +65,4 @@ class NtfyCommonBaseEntity(CoordinatorEntity[BaseDataUpdateCoordinator]):
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
- self._attr_device_info = DeviceInfo(
- entry_type=DeviceEntryType.SERVICE,
- manufacturer="ntfy LLC",
- model="ntfy",
- configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app",
- identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
- )
+ self._attr_device_info = coordinator.device_info
diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py
index f29491ba8a55..c0fb60bb4386 100644
--- a/homeassistant/components/ondilo_ico/coordinator.py
+++ b/homeassistant/components/ondilo_ico/coordinator.py
@@ -98,10 +98,7 @@ class OndiloIcoPoolsCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoPoolDat
self.config_entry.entry_id,
)
if device_entry:
- self._device_registry.async_update_device(
- device_id=device_entry.id,
- remove_config_entry_id=self.config_entry.entry_id,
- )
+ self._device_registry.async_remove_device(device_entry.id)
for pool_id in current_pools:
pool_data = data[pool_id]
diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py
index 3e4b98091605..931d130cd1ea 100644
--- a/homeassistant/components/onvif/config_flow.py
+++ b/homeassistant/components/onvif/config_flow.py
@@ -186,14 +186,16 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN):
mac = discovery_info.macaddress
registry = dr.async_get(self.hass)
if not (
- device := registry.async_get_device(
+ devices := registry.async_get_devices(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
):
return self.async_abort(reason="no_devices_found")
- for entry_id in device.config_entries:
+ for device in devices:
if (
- not (entry := hass.config_entries.async_get_entry(entry_id))
+ not (
+ entry := hass.config_entries.async_get_entry(device.config_entry_id)
+ )
or entry.domain != DOMAIN
or entry.state is ConfigEntryState.LOADED
):
@@ -201,7 +203,9 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN):
if hass.config_entries.async_update_entry(
entry, data=entry.data | {CONF_HOST: discovery_info.ip}
):
- hass.async_create_task(self.hass.config_entries.async_reload(entry_id))
+ hass.async_create_task(
+ self.hass.config_entries.async_reload(entry.entry_id)
+ )
return self.async_abort(reason="already_configured")
async def async_step_device(
diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py
index 3cb4d01ce1b3..87bd4a333caf 100644
--- a/homeassistant/components/overkiz/__init__.py
+++ b/homeassistant/components/overkiz/__init__.py
@@ -193,6 +193,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
device_registry = dr.async_get(hass)
+ registered_gateway_ids = {gateway.id for gateway in setup.gateways}
+
for gateway in setup.gateways:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
@@ -208,6 +210,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
configuration_url=client.server_config.configuration_url,
)
+ # Some devices reference a gateway that is not part of setup.gateways
+ # (e.g. a secondary box hosting a device). Register a device for each such
+ # gateway so the via_device link of its child devices resolves.
+ for gateway_id in {
+ device.identifier.gateway_id for device in coordinator.data.values()
+ } - registered_gateway_ids:
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, gateway_id)},
+ manufacturer=client.server_config.manufacturer,
+ name=gateway_id,
+ configuration_url=client.server_config.configuration_url,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py
index 4de532489577..7d259cb2ef37 100644
--- a/homeassistant/components/overkiz/coordinator.py
+++ b/homeassistant/components/overkiz/coordinator.py
@@ -216,11 +216,7 @@ async def on_device_removed(
if registered_device := registry.async_get_device_by_identifier(
(DOMAIN, base_device_url), coordinator.config_entry.entry_id
):
- # Detach only this entry; the registry deletes the device once none remain.
- registry.async_update_device(
- registered_device.id,
- remove_config_entry_id=coordinator.config_entry.entry_id,
- )
+ registry.async_remove_device(registered_device.id)
if event.device_url in coordinator.devices:
del coordinator.devices[event.device_url]
diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py
index 97560335b283..61d1d84a3b4b 100644
--- a/homeassistant/components/overkiz/entity.py
+++ b/homeassistant/components/overkiz/entity.py
@@ -5,6 +5,7 @@ from typing import cast, override
from pyoverkiz.enums import APIType, OverkizAttribute, OverkizCommandParam, OverkizState
from pyoverkiz.models import Device
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -111,7 +112,11 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
model_id=self.device.widget,
hw_version=self.device.controllable_name,
suggested_area=suggested_area,
- via_device=(DOMAIN, self.device.identifier.gateway_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, self.device.identifier.gateway_id),
+ config_entry_id=self.coordinator.config_entry.entry_id,
+ ),
configuration_url=self.coordinator.client.server_config.configuration_url,
)
diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py
index e0ac391f68b9..6b2d94920d44 100644
--- a/homeassistant/components/playstation_network/__init__.py
+++ b/homeassistant/components/playstation_network/__init__.py
@@ -1,9 +1,13 @@
"""The PlayStation Network integration."""
+from typing import TYPE_CHECKING
+
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import DeviceEntryType
-from .const import CONF_NPSSO
+from .const import CONF_NPSSO, DOMAIN
from .coordinator import (
PlaystationNetworkConfigEntry,
PlaystationNetworkFriendDataCoordinator,
@@ -54,6 +58,18 @@ async def async_setup_entry(
coordinator, trophy_titles, groups, friends, friends_list
)
+ # Register the account device up front so entities on concurrently set up
+ # platforms (media players, friends) can resolve it as their via_device.
+ if TYPE_CHECKING:
+ assert entry.unique_id
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, entry.unique_id)},
+ name=coordinator.data.username,
+ entry_type=DeviceEntryType.SERVICE,
+ manufacturer="Sony Interactive Entertainment",
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py
index dc1f126505cb..a4a1757d07f7 100644
--- a/homeassistant/components/playstation_network/entity.py
+++ b/homeassistant/components/playstation_network/entity.py
@@ -3,6 +3,7 @@
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigSubentry
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -50,5 +51,11 @@ class PlaystationNetworkServiceEntity(
)
if subentry:
self._attr_device_info.update(
- DeviceInfo(via_device=(DOMAIN, coordinator.config_entry.unique_id))
+ DeviceInfo(
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.unique_id),
+ config_entry_id=coordinator.config_entry.entry_id,
+ )
+ )
)
diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py
index fa94e76c49ce..9b45ef1d0d2b 100644
--- a/homeassistant/components/playstation_network/media_player.py
+++ b/homeassistant/components/playstation_network/media_player.py
@@ -109,7 +109,11 @@ class PsnMediaPlayerEntity(
name=PLATFORM_MAP[platform],
manufacturer="Sony Interactive Entertainment",
model=PLATFORM_MAP[platform],
- via_device=(DOMAIN, coordinator.config_entry.unique_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.unique_id),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
self.trophy_titles = trophy_titles
diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py
index 2429529336f4..943b3222e221 100644
--- a/homeassistant/components/plugwise/coordinator.py
+++ b/homeassistant/components/plugwise/coordinator.py
@@ -165,9 +165,7 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
(DOMAIN, device_id), self.config_entry.entry_id
)
) is not None:
- device_reg.async_update_device(
- device_entry.id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_reg.async_remove_device(device_entry.id)
LOGGER.debug(
"%s %s %s removed from device_registry",
DOMAIN,
diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py
index 4f3757a16545..86123138de15 100644
--- a/homeassistant/components/point/coordinator.py
+++ b/homeassistant/components/point/coordinator.py
@@ -9,6 +9,7 @@ from pypoint import PointSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.dt import parse_datetime
@@ -50,7 +51,16 @@ class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
if new_homes := set(self.point.homes) - self._known_homes:
_LOGGER.debug("Found new homes: %s", new_homes)
+ device_registry = dr.async_get(self.hass)
for home_id in new_homes:
+ # Register the home before its child devices so they can resolve
+ # it as their via_device parent when their entities are created.
+ device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ identifiers={(DOMAIN, home_id)},
+ manufacturer="Minut",
+ name=self.point.homes[home_id]["name"],
+ )
if self.new_home_callback:
self.new_home_callback(home_id)
self._known_homes.update(new_homes)
diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py
index 9d4e5c1a6a84..cac2bc4fbea0 100644
--- a/homeassistant/components/point/entity.py
+++ b/homeassistant/components/point/entity.py
@@ -33,8 +33,11 @@ class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]):
name=device["description"],
hw_version=device["hardware_version"],
sw_version=device["firmware"]["installed"],
- via_device=(DOMAIN, device["home"]),
)
+ if parent := dr.async_get(coordinator.hass).async_get_device_by_identifier(
+ (DOMAIN, device["home"]), coordinator.config_entry.entry_id
+ ):
+ self._attr_device_info["via_device_id"] = parent.id
if self.device_class:
self._attr_name = f"{self._name} {self.device_class.capitalize()}"
diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py
index 6465934e3ba8..e5dc60f45295 100644
--- a/homeassistant/components/portainer/__init__.py
+++ b/homeassistant/components/portainer/__init__.py
@@ -90,6 +90,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
entry.async_on_unload(async_at_started(hass, _defer_docker_disk_space_refresh))
entry.runtime_data = coordinator
+
+ # Register the endpoint and stack devices up front so that container, stack and
+ # volume entities can deterministically resolve them as their via device when
+ # their platforms are set up below, regardless of platform/entity add order.
+ coordinator.async_register_endpoint_and_stack_devices(
+ coordinator.data,
+ set(coordinator.data),
+ {
+ (endpoint_id, stack_name, stack_data.stack.id)
+ for endpoint_id, endpoint_data in coordinator.data.items()
+ for stack_name, stack_data in endpoint_data.stacks.items()
+ },
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
@callback
diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py
index a199fd079c68..66e03cbc4679 100644
--- a/homeassistant/components/portainer/coordinator.py
+++ b/homeassistant/components/portainer/coordinator.py
@@ -30,14 +30,17 @@ from pyportainer.models.docker_inspect import DockerInfo, DockerInspect, DockerV
from pyportainer.models.portainer import Endpoint
from pyportainer.models.stacks import Stack
from pyportainer.watcher import PortainerImageWatcher
+from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import DOMAIN
+from .const import DEFAULT_NAME, DOMAIN
from .util import sanitize_container_name
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
@@ -114,7 +117,7 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
self.known_endpoints: set[int] = set()
self.known_containers: set[tuple[int, str]] = set()
- self.known_stacks: set[tuple[int, str]] = set()
+ self.known_stacks: set[tuple[int, str, int]] = set()
self.known_volumes: set[tuple[int, str]] = set()
self.new_endpoints_callbacks: list[
@@ -411,6 +414,57 @@ class PortainerCoordinator(
return mapped_endpoints
+ def async_register_endpoint_and_stack_devices(
+ self,
+ mapped_endpoints: dict[int, PortainerCoordinatorData],
+ endpoint_ids: set[int],
+ stack_keys: set[tuple[int, str, int]],
+ ) -> None:
+ """Register endpoint and stack devices.
+
+ Must run for an endpoint/stack before any entity that resolves it
+ as its via device is constructed, both during initial setup and
+ when a later refresh discovers new endpoints or stacks.
+ """
+ device_registry = dr.async_get(self.hass)
+
+ for endpoint_id in endpoint_ids:
+ endpoint = mapped_endpoints[endpoint_id].endpoint
+ device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ identifiers={(DOMAIN, f"{self.config_entry.entry_id}_{endpoint_id}")},
+ configuration_url=URL(
+ f"{self.config_entry.data[CONF_URL]}#!/{endpoint_id}/docker/dashboard"
+ ),
+ manufacturer=DEFAULT_NAME,
+ model="Endpoint",
+ name=endpoint.name,
+ entry_type=DeviceEntryType.SERVICE,
+ )
+
+ for endpoint_id, stack_name, _stack_id in stack_keys:
+ stack = mapped_endpoints[endpoint_id].stacks[stack_name].stack
+ device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ identifiers={
+ (
+ DOMAIN,
+ f"{self.config_entry.entry_id}_{endpoint_id}_stack_{stack.id}",
+ )
+ },
+ configuration_url=URL(
+ f"{self.config_entry.data[CONF_URL]}#!/{endpoint_id}/docker/stacks/{stack.name}"
+ ),
+ manufacturer=DEFAULT_NAME,
+ model="Stack",
+ name=stack.name,
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.hass,
+ (DOMAIN, f"{self.config_entry.entry_id}_{endpoint_id}"),
+ config_entry_id=self.config_entry.entry_id,
+ ),
+ )
+
def _async_add_remove_endpoints(
self, mapped_endpoints: dict[int, PortainerCoordinatorData]
) -> None:
@@ -418,6 +472,25 @@ class PortainerCoordinator(
current_endpoints = {endpoint.id for endpoint in mapped_endpoints.values()}
self.known_endpoints &= current_endpoints
new_endpoints = current_endpoints - self.known_endpoints
+
+ # The stack ID is part of the key because it is part of the stack device
+ # identifier; a stack recreated with the same name but a new ID must be
+ # detected as new so its device is (re)registered before entities resolve it.
+ current_stacks = {
+ (endpoint.id, stack_name, stack_data.stack.id)
+ for endpoint in mapped_endpoints.values()
+ for stack_name, stack_data in endpoint.stacks.items()
+ }
+ self.known_stacks &= current_stacks
+ new_stacks = current_stacks - self.known_stacks
+
+ if new_endpoints or new_stacks:
+ # Register devices before any callback below constructs an
+ # entity that resolves one of them as its via device.
+ self.async_register_endpoint_and_stack_devices(
+ mapped_endpoints, new_endpoints, new_stacks
+ )
+
if new_endpoints:
_LOGGER.debug("New endpoints found: %s", new_endpoints)
self.known_endpoints.update(new_endpoints)
@@ -473,14 +546,6 @@ class PortainerCoordinator(
volume_callback(new_volume_data)
# Stack management
- current_stacks = {
- (endpoint.id, stack_name)
- for endpoint in mapped_endpoints.values()
- for stack_name in endpoint.stacks
- }
-
- self.known_stacks &= current_stacks
- new_stacks = current_stacks - self.known_stacks
if new_stacks:
_LOGGER.debug("New stacks found: %s", new_stacks)
self.known_stacks.update(new_stacks)
@@ -489,7 +554,7 @@ class PortainerCoordinator(
mapped_endpoints[endpoint_id],
mapped_endpoints[endpoint_id].stacks[name],
)
- for endpoint_id, name in new_stacks
+ for endpoint_id, name, _stack_id in new_stacks
]
for stack_callback in self.new_stacks_callbacks:
stack_callback(new_stack_data)
diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py
index 2c9358a3db42..6ff05f1a82f0 100644
--- a/homeassistant/components/portainer/entity.py
+++ b/homeassistant/components/portainer/entity.py
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, override
from yarl import URL
from homeassistant.const import CONF_URL
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -114,11 +115,15 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
name=self.device_name,
# If the container belongs to a stack, nest it under the stack
# else it's the endpoint
- via_device=(
- DOMAIN,
- f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_stack_{device_info.stack.id}"
- if device_info.stack
- else f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (
+ DOMAIN,
+ f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_stack_{device_info.stack.id}"
+ if device_info.stack
+ else f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
+ ),
+ config_entry_id=coordinator.config_entry.entry_id,
),
translation_key=None if self.device_name else "unknown_container",
entry_type=DeviceEntryType.SERVICE,
@@ -176,9 +181,10 @@ class PortainerStackEntity(PortainerCoordinatorEntity):
),
model="Stack",
name=self.device_name,
- via_device=(
- DOMAIN,
- f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.endpoint_id}"),
+ config_entry_id=coordinator.config_entry.entry_id,
),
)
self._attr_unique_id = (
@@ -280,9 +286,10 @@ class PortainerVolumeEntity(PortainerCoordinatorEntity):
),
model="Volume",
name=self.volume_name,
- via_device=(
- DOMAIN,
- f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.endpoint_id}"),
+ config_entry_id=coordinator.config_entry.entry_id,
),
)
self._attr_unique_id = (
diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py
index 71b53573e7c3..cfd666d13ffd 100644
--- a/homeassistant/components/powerwall/__init__.py
+++ b/homeassistant/components/powerwall/__init__.py
@@ -28,6 +28,8 @@ from .const import (
AUTH_COOKIE_KEY,
CONFIG_ENTRY_COOKIE,
DOMAIN,
+ MANUFACTURER,
+ MODEL,
POWERWALL_API_CHANGED,
POWERWALL_COORDINATOR,
)
@@ -238,6 +240,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) ->
await async_migrate_entity_unique_ids(hass, entry, base_info)
+ # Register the gateway device so battery devices can link to it via
+ # via_device_id, which must resolve to an already-registered device.
+ model = (
+ f"{MODEL} ({base_info.device_type.name})"
+ if base_info.device_type is not None
+ else MODEL
+ )
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, gateway_din)},
+ manufacturer=MANUFACTURER,
+ model=model,
+ name=base_info.site_name,
+ sw_version=base_info.status.version if base_info.status else None,
+ configuration_url=base_info.url,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py
index 71588145cf04..f1b1cf32ef18 100644
--- a/homeassistant/components/powerwall/entity.py
+++ b/homeassistant/components/powerwall/entity.py
@@ -2,6 +2,7 @@
from tesla_powerwall import BatteryResponse
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -65,7 +66,6 @@ class BatteryEntity(CoordinatorEntity[PowerwallUpdateCoordinator]):
self.serial_number = battery.serial_number
self.power_wall = powerwall_data[POWERWALL_API]
self.base_unique_id = f"{base_info.gateway_din}_{battery.serial_number}"
-
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.base_unique_id)},
manufacturer=MANUFACTURER,
@@ -73,7 +73,11 @@ class BatteryEntity(CoordinatorEntity[PowerwallUpdateCoordinator]):
name=f"{base_info.site_name} {battery.serial_number}",
sw_version=base_info.status.version if base_info.status else None,
configuration_url=base_info.url,
- via_device=(DOMAIN, base_info.gateway_din),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, base_info.gateway_din),
+ config_entry_id=self.coordinator.config_entry.entry_id,
+ ),
)
@property
diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py
index 767a3ae10280..84742a2b188b 100644
--- a/homeassistant/components/purpleair/config_flow.py
+++ b/homeassistant/components/purpleair/config_flow.py
@@ -438,9 +438,7 @@ class PurpleAirOptionsFlowHandler(OptionsFlowWithReload):
[entity_entry.entity_id for entity_entry in entity_entries],
async_device_entity_state_changed,
)
- device_registry.async_update_device(
- device_id, remove_config_entry_id=self.config_entry.entry_id
- )
+ device_registry.async_remove_device(device_id)
await device_entities_removed_event.wait()
# Once we're done, we can cancel the state change tracker callback:
diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py
index ef73a8d70fac..3a7ae8f12a43 100644
--- a/homeassistant/components/qbus/entity.py
+++ b/homeassistant/components/qbus/entity.py
@@ -3,13 +3,14 @@
from abc import ABC, abstractmethod
from collections.abc import Callable
import re
-from typing import cast, override
+from typing import TYPE_CHECKING, cast, override
from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput
from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
from qbusmqttapi.state import QbusMqttState
from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
@@ -92,6 +93,7 @@ class QbusEntity[StateT: QbusMqttState](Entity, ABC):
"""Initialize the Qbus entity."""
self._mqtt_output = mqtt_output
+ self._link_to_main_device = link_to_main_device
self._topic_factory = QbusMqttTopicFactory()
self._message_factory = QbusMqttMessageFactory()
@@ -99,8 +101,8 @@ class QbusEntity[StateT: QbusMqttState](Entity, ABC):
mqtt_output.device.id, mqtt_output.id
)
- ref_id = format_ref_id(mqtt_output.ref_id)
- suffix = ref_id or ""
+ self._ref_id = format_ref_id(mqtt_output.ref_id)
+ suffix = self._ref_id or ""
if id_suffix:
suffix += f"_{id_suffix}"
@@ -109,19 +111,32 @@ class QbusEntity[StateT: QbusMqttState](Entity, ABC):
mqtt_output.device.serial_number, suffix
)
- if link_to_main_device:
- self._attr_device_info = DeviceInfo(
- identifiers={create_device_identifier(mqtt_output.device)}
- )
- else:
- self._attr_device_info = DeviceInfo(
- name=mqtt_output.name.title(),
- manufacturer=MANUFACTURER,
- identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
- suggested_area=mqtt_output.location.title(),
- via_device=create_device_identifier(mqtt_output.device),
+ @property
+ @override
+ def device_info(self) -> DeviceInfo:
+ """Return the device info."""
+ if self._link_to_main_device:
+ return DeviceInfo(
+ identifiers={create_device_identifier(self._mqtt_output.device)}
)
+ config_entry = self.platform.config_entry
+ if TYPE_CHECKING:
+ assert config_entry is not None
+ return DeviceInfo(
+ name=self._mqtt_output.name.title(),
+ manufacturer=MANUFACTURER,
+ identifiers={
+ (DOMAIN, f"{self._mqtt_output.device.serial_number}_{self._ref_id}")
+ },
+ suggested_area=self._mqtt_output.location.title(),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.hass,
+ create_device_identifier(self._mqtt_output.device),
+ config_entry_id=config_entry.entry_id,
+ ),
+ )
+
@override
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py
index 57f4f5f7e012..3d0a8b2b2f03 100644
--- a/homeassistant/components/rainbird/__init__.py
+++ b/homeassistant/components/rainbird/__init__.py
@@ -130,6 +130,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) ->
await data.coordinator.async_config_entry_first_refresh()
entry.runtime_data = data
+
+ # Register the controller device up front so that child zone/switch devices
+ # can resolve their via_device_id link during platform setup, regardless of
+ # which platform loads first.
+ if device_info := data.coordinator.device_info:
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id, **device_info
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_listener))
diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py
index e8bef2de36ad..efffb7a12eec 100644
--- a/homeassistant/components/rainbird/switch.py
+++ b/homeassistant/components/rainbird/switch.py
@@ -8,6 +8,7 @@ from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyExcept
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -66,7 +67,11 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity)
name=device_name,
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer=MANUFACTURER,
- via_device=(DOMAIN, coordinator.unique_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.unique_id),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
@property
diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py
index b33459570a2c..396e39f9b22c 100644
--- a/homeassistant/components/ring/config_flow.py
+++ b/homeassistant/components/ring/config_flow.py
@@ -90,7 +90,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
if self.hass.config_entries.async_has_entries(DOMAIN):
device_registry = dr.async_get(self.hass)
- if device_registry.async_get_device(
+ if device_registry.async_get_devices(
identifiers={(DOMAIN, discovery_info.macaddress)}
):
return self.async_abort(reason="already_configured")
diff --git a/homeassistant/components/risco/services.py b/homeassistant/components/risco/services.py
index ebbcf4ba0cfd..dc45511c7b73 100644
--- a/homeassistant/components/risco/services.py
+++ b/homeassistant/components/risco/services.py
@@ -1,13 +1,12 @@
"""Services for Risco integration."""
-from datetime import datetime
-
import voluptuous as vol
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, SERVICE_SET_TIME
from .models import RiscoConfigEntry
@@ -31,7 +30,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
time_to_send = time
if time is None:
- time_to_send = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
+ time_to_send = dt_util.now()
await local_data.system.set_time(time_to_send)
diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py
index 41381ea24836..559e8553ac75 100644
--- a/homeassistant/components/russound_rio/__init__.py
+++ b/homeassistant/components/russound_rio/__init__.py
@@ -73,13 +73,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
or f"{client.controllers[1].mac_address}-{controller_id}"
)
connections = None
- via_device = None
+ via_device_id: str | None = None
configuration_url = None
if controller_id != 1:
assert client.controllers[1].mac_address
- via_device = (
- DOMAIN,
- client.controllers[1].mac_address,
+ via_device_id = dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, client.controllers[1].mac_address),
+ config_entry_id=entry.entry_id,
)
else:
assert controller.mac_address
@@ -94,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
model=controller.controller_type,
sw_version=controller.firmware_version,
connections=connections,
- via_device=via_device,
+ via_device_id=via_device_id,
configuration_url=configuration_url,
)
diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py
index 51c6482ab4d0..54129f5ecbcc 100644
--- a/homeassistant/components/russound_rio/entity.py
+++ b/homeassistant/components/russound_rio/entity.py
@@ -2,18 +2,23 @@
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
-from typing import Any, Concatenate, override
+from typing import TYPE_CHECKING, Any, Concatenate, override
from aiorussound.rio import RussoundRIOClient
from aiorussound.rio.client import Controller, ZoneControlSurface
from aiorussound.rio.models import CallbackType
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS
+if TYPE_CHECKING:
+ from . import RussoundConfigEntry
+
def command[_EntityT: RussoundBaseEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Awaitable[None]],
@@ -46,7 +51,9 @@ class RussoundBaseEntity(Entity):
def __init__(
self,
+ hass: HomeAssistant,
controller: Controller,
+ entry: RussoundConfigEntry,
zone_id: int | None = None,
) -> None:
"""Initialize the entity."""
@@ -73,7 +80,11 @@ class RussoundBaseEntity(Entity):
model=controller.controller_type,
sw_version=controller.firmware_version,
suggested_area=zone.name,
- via_device=(DOMAIN, self._device_identifier),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, self._device_identifier),
+ config_entry_id=entry.entry_id,
+ ),
)
@property
diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py
index 79e76d15e15b..6c28bcdba967 100644
--- a/homeassistant/components/russound_rio/media_player.py
+++ b/homeassistant/components/russound_rio/media_player.py
@@ -51,7 +51,9 @@ async def async_setup_entry(
)
async_add_entities(
- RussoundZoneDevice(controller, zone_id, sources, zone_source_exclusion)
+ RussoundZoneDevice(
+ hass, controller, entry, zone_id, sources, zone_source_exclusion
+ )
for controller in client.controllers.values()
for zone_id in controller.zones
)
@@ -88,13 +90,15 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
def __init__(
self,
+ hass: HomeAssistant,
controller: Controller,
+ entry: RussoundConfigEntry,
zone_id: int,
sources: dict[int, Source],
zone_source_exclusion: bool,
) -> None:
"""Initialize the zone device."""
- super().__init__(controller, zone_id)
+ super().__init__(hass, controller, entry, zone_id)
_zone = self._zone
self._sources = sources
self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}"
diff --git a/homeassistant/components/russound_rio/number.py b/homeassistant/components/russound_rio/number.py
index 48930ec6f64e..eb7be8dbd3a8 100644
--- a/homeassistant/components/russound_rio/number.py
+++ b/homeassistant/components/russound_rio/number.py
@@ -77,7 +77,7 @@ async def async_setup_entry(
"""Set up Russound number entities based on a config entry."""
client = entry.runtime_data
async_add_entities(
- RussoundNumberEntity(controller, zone_id, description)
+ RussoundNumberEntity(hass, controller, entry, zone_id, description)
for controller in client.controllers.values()
for zone_id in controller.zones
for description in CONTROL_ENTITIES
@@ -91,12 +91,14 @@ class RussoundNumberEntity(RussoundBaseEntity, NumberEntity):
def __init__(
self,
+ hass: HomeAssistant,
controller: Controller,
+ entry: RussoundConfigEntry,
zone_id: int,
description: RussoundZoneNumberEntityDescription,
) -> None:
"""Initialize a Russound number entity."""
- super().__init__(controller, zone_id)
+ super().__init__(hass, controller, entry, zone_id)
self.entity_description = description
self._attr_unique_id = (
f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}"
diff --git a/homeassistant/components/russound_rio/select.py b/homeassistant/components/russound_rio/select.py
index 9c6b3eb7a289..a91a97598760 100644
--- a/homeassistant/components/russound_rio/select.py
+++ b/homeassistant/components/russound_rio/select.py
@@ -50,7 +50,7 @@ async def async_setup_entry(
"""Set up Russound RIO select entities based on a config entry."""
client = entry.runtime_data
async_add_entities(
- RussoundSelectEntity(controller, zone_id, description)
+ RussoundSelectEntity(hass, controller, entry, zone_id, description)
for controller in client.controllers.values()
for zone_id in controller.zones
for description in CONTROL_ENTITIES
@@ -64,12 +64,14 @@ class RussoundSelectEntity(RussoundBaseEntity, SelectEntity):
def __init__(
self,
+ hass: HomeAssistant,
controller: Controller,
+ entry: RussoundConfigEntry,
zone_id: int,
description: RussoundZoneSelectEntityDescription,
) -> None:
"""Initialize Russound RIO select."""
- super().__init__(controller, zone_id)
+ super().__init__(hass, controller, entry, zone_id)
self.entity_description = description
self._attr_unique_id = (
f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}"
diff --git a/homeassistant/components/russound_rio/switch.py b/homeassistant/components/russound_rio/switch.py
index eeb8f095fa57..7254cc85233a 100644
--- a/homeassistant/components/russound_rio/switch.py
+++ b/homeassistant/components/russound_rio/switch.py
@@ -44,7 +44,7 @@ async def async_setup_entry(
"""Set up Russound RIO switch entities based on a config entry."""
client = entry.runtime_data
async_add_entities(
- RussoundSwitchEntity(controller, zone_id, description)
+ RussoundSwitchEntity(hass, controller, entry, zone_id, description)
for controller in client.controllers.values()
for zone_id in controller.zones
for description in CONTROL_ENTITIES
@@ -58,12 +58,14 @@ class RussoundSwitchEntity(RussoundBaseEntity, SwitchEntity):
def __init__(
self,
+ hass: HomeAssistant,
controller: Controller,
+ entry: RussoundConfigEntry,
zone_id: int,
description: RussoundZoneSwitchEntityDescription,
) -> None:
"""Initialize Russound RIO switch."""
- super().__init__(controller, zone_id)
+ super().__init__(hass, controller, entry, zone_id)
self.entity_description = description
self._attr_unique_id = (
f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}"
diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py
index fbd49cecb399..95f9165f8cfd 100644
--- a/homeassistant/components/sense/__init__.py
+++ b/homeassistant/components/sense/__init__.py
@@ -15,10 +15,12 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TIMEOUT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
ACTIVE_UPDATE_RATE,
+ DOMAIN,
SENSE_CONNECT_EXCEPTIONS,
SENSE_TIMEOUT_EXCEPTIONS,
SENSE_WEBSOCKET_EXCEPTIONS,
@@ -117,6 +119,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo
rt=realtime_coordinator,
)
+ # Register the monitor device up front so child devices can reference it via
+ # via_device_id; child entities are added by concurrently-loaded platforms
+ # before any of them registers the monitor device.
+ sense_monitor_id = gateway.sense_monitor_id
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ name=f"Sense {sense_monitor_id}",
+ identifiers={(DOMAIN, sense_monitor_id)},
+ model="Sense",
+ manufacturer="Sense Labs, Inc.",
+ configuration_url="https://home.sense.com",
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/sense/entity.py b/homeassistant/components/sense/entity.py
index 35c556a51f2c..b9ae487ebb38 100644
--- a/homeassistant/components/sense/entity.py
+++ b/homeassistant/components/sense/entity.py
@@ -3,6 +3,7 @@
from sense_energy import ASyncSenseable
from sense_energy.sense_api import SenseDevice
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -67,5 +68,9 @@ class SenseDeviceEntity(CoordinatorEntity[SenseCoordinator]):
model="Sense",
manufacturer="Sense Labs, Inc.",
configuration_url="https://home.sense.com",
- via_device=(DOMAIN, sense_monitor_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, sense_monitor_id),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py
index d0a91db2b630..de2653a29bad 100644
--- a/homeassistant/components/sensibo/__init__.py
+++ b/homeassistant/components/sensibo/__init__.py
@@ -6,12 +6,17 @@ from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+)
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER, PLATFORMS
from .coordinator import SensiboDataUpdateCoordinator
+from .entity import get_device_info
from .services import async_setup_services
from .util import NoDevicesError, NoUsernameError, async_validate_api
@@ -34,6 +39,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> b
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
+ device_registry = dr.async_get(hass)
+
+ def _async_register_devices() -> None:
+ """Register parent AC devices so motion sensors can resolve via_device_id.
+
+ Registered before the platforms so it runs first on every coordinator
+ update, ensuring a parent device exists before the (concurrently set up)
+ motion sensors resolve it, including devices added dynamically at runtime.
+ """
+ for device in coordinator.data.parsed.values():
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id, **get_device_info(device)
+ )
+
+ _async_register_devices()
+ entry.async_on_unload(coordinator.async_add_listener(_async_register_devices))
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py
index 8fdd867b577f..d34256f54941 100644
--- a/homeassistant/components/sensibo/coordinator.py
+++ b/homeassistant/components/sensibo/coordinator.py
@@ -114,10 +114,7 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
(DOMAIN, _id), self.config_entry.entry_id
)
if device:
- 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)
self.previous_devices = current_devices
return data
diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py
index 9544e0d52cff..8bf81df9ca51 100644
--- a/homeassistant/components/sensibo/entity.py
+++ b/homeassistant/components/sensibo/entity.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Concatenate, override
from pysensibo.model import MotionSensor, SensiboDevice
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -14,6 +15,22 @@ from .const import DOMAIN, LOGGER, SENSIBO_ERRORS, TIMEOUT
from .coordinator import SensiboDataUpdateCoordinator
+def get_device_info(device: SensiboDevice) -> DeviceInfo:
+ """Return device info for a Sensibo device."""
+ return DeviceInfo(
+ identifiers={(DOMAIN, device.id)},
+ name=device.name,
+ connections={(CONNECTION_NETWORK_MAC, device.mac)},
+ manufacturer="Sensibo",
+ configuration_url="https://home.sensibo.com/",
+ model=device.model,
+ sw_version=device.fw_ver,
+ hw_version=device.fw_type,
+ suggested_area=device.name,
+ serial_number=device.serial,
+ )
+
+
def async_handle_api_call[_T: SensiboDeviceBaseEntity, **_P](
function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]:
@@ -90,18 +107,7 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity):
) -> None:
"""Initiate Sensibo Device."""
super().__init__(coordinator, device_id)
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, self.device_data.id)},
- name=self.device_data.name,
- connections={(CONNECTION_NETWORK_MAC, self.device_data.mac)},
- manufacturer="Sensibo",
- configuration_url="https://home.sensibo.com/",
- model=self.device_data.model,
- sw_version=self.device_data.fw_ver,
- hw_version=self.device_data.fw_type,
- suggested_area=self.device_data.name,
- serial_number=self.device_data.serial,
- )
+ self._attr_device_info = get_device_info(self.device_data)
class SensiboMotionBaseEntity(SensiboBaseEntity):
@@ -120,7 +126,11 @@ class SensiboMotionBaseEntity(SensiboBaseEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, sensor_id)},
name=f"{self.device_data.name} Motion Sensor",
- via_device=(DOMAIN, device_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, device_id),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
manufacturer="Sensibo",
configuration_url="https://home.sensibo.com/",
model=sensor_data.model,
diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py
index cf44fb41b700..b130d5b0c817 100644
--- a/homeassistant/components/sia/alarm_control_panel.py
+++ b/homeassistant/components/sia/alarm_control_panel.py
@@ -72,7 +72,7 @@ async def async_setup_entry(
"""Set up SIA alarm_control_panel(s) from a config entry."""
async_add_entities(
SIAAlarmControlPanel(
- entry, account_data[CONF_ACCOUNT], zone, ENTITY_DESCRIPTION_ALARM
+ hass, entry, account_data[CONF_ACCOUNT], zone, ENTITY_DESCRIPTION_ALARM
)
for account_data in entry.data[CONF_ACCOUNTS]
for zone in range(
@@ -89,6 +89,7 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity):
def __init__(
self,
+ hass: HomeAssistant,
entry: ConfigEntry,
account: str,
zone: int,
@@ -96,6 +97,7 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity):
) -> None:
"""Create SIAAlarmControlPanel object."""
super().__init__(
+ hass,
entry,
account,
zone,
diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py
index 8c15fdd1a740..95e8c38ee20a 100644
--- a/homeassistant/components/sia/binary_sensor.py
+++ b/homeassistant/components/sia/binary_sensor.py
@@ -82,7 +82,9 @@ ENTITY_DESCRIPTION_CONNECTIVITY = SIABinarySensorEntityDescription(
)
-def generate_binary_sensors(entry: ConfigEntry) -> Iterable[SIABinarySensor]:
+def generate_binary_sensors(
+ hass: HomeAssistant, entry: ConfigEntry
+) -> Iterable[SIABinarySensor]:
"""Generate binary sensors.
For each Account there is one power sensor with zone == 0.
@@ -93,12 +95,16 @@ def generate_binary_sensors(entry: ConfigEntry) -> Iterable[SIABinarySensor]:
zones = entry.options[CONF_ACCOUNTS][account][CONF_ZONES]
yield SIABinarySensorConnectivity(
- entry, account, SIA_HUB_ZONE, ENTITY_DESCRIPTION_CONNECTIVITY
+ hass, entry, account, SIA_HUB_ZONE, ENTITY_DESCRIPTION_CONNECTIVITY
+ )
+ yield SIABinarySensor(
+ hass, entry, account, SIA_HUB_ZONE, ENTITY_DESCRIPTION_POWER
)
- yield SIABinarySensor(entry, account, SIA_HUB_ZONE, ENTITY_DESCRIPTION_POWER)
for zone in range(1, zones + 1):
- yield SIABinarySensor(entry, account, zone, ENTITY_DESCRIPTION_SMOKE)
- yield SIABinarySensor(entry, account, zone, ENTITY_DESCRIPTION_MOISTURE)
+ yield SIABinarySensor(hass, entry, account, zone, ENTITY_DESCRIPTION_SMOKE)
+ yield SIABinarySensor(
+ hass, entry, account, zone, ENTITY_DESCRIPTION_MOISTURE
+ )
async def async_setup_entry(
@@ -107,7 +113,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SIA binary sensors from a config entry."""
- async_add_entities(generate_binary_sensors(entry))
+ async_add_entities(generate_binary_sensors(hass, entry))
class SIABinarySensor(SIABaseEntity, BinarySensorEntity):
diff --git a/homeassistant/components/sia/entity.py b/homeassistant/components/sia/entity.py
index a8eeb446380b..3917524724cc 100644
--- a/homeassistant/components/sia/entity.py
+++ b/homeassistant/components/sia/entity.py
@@ -10,7 +10,8 @@ from pysiaalarm import SIAEvent
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
-from homeassistant.core import CALLBACK_TYPE, State, callback
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityDescription
@@ -55,6 +56,7 @@ class SIABaseEntity(RestoreEntity):
def __init__(
self,
+ hass: HomeAssistant,
entry: ConfigEntry,
account: str,
zone: int,
@@ -77,7 +79,11 @@ class SIABaseEntity(RestoreEntity):
self._attr_device_info = DeviceInfo(
name=self._attr_name,
identifiers={(DOMAIN, self._attr_unique_id)},
- via_device=(DOMAIN, f"{entry.data[CONF_PORT]}_{account}"),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, f"{self.port}_{self.account}"),
+ config_entry_id=entry.entry_id,
+ ),
)
self._post_interval_update_cb_canceller: CALLBACK_TYPE | None = None
diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py
index 09cea70fa99f..14cb480a23d4 100644
--- a/homeassistant/components/smartthings/__init__.py
+++ b/homeassistant/components/smartthings/__init__.py
@@ -248,9 +248,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
(DOMAIN, device_id), entry.entry_id
)
if dev_entry is not None:
- device_registry.async_update_device(
- dev_entry.id, remove_config_entry_id=entry.entry_id
- )
+ device_registry.async_remove_device(dev_entry.id)
entry.async_on_unload(
client.add_device_lifecycle_event_listener(
diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py
index 1b384c5e0337..b54e115cb80f 100644
--- a/homeassistant/components/smartthings/climate.py
+++ b/homeassistant/components/smartthings/climate.py
@@ -26,6 +26,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -165,7 +166,9 @@ async def async_setup_entry(
)
)
entities.extend(
- SmartThingsHeatPumpZone(entry_data.client, device, component)
+ SmartThingsHeatPumpZone(
+ hass, entry_data.client, device, component, entry.entry_id
+ )
for device in entry_data.devices.values()
for component in device.status
if component in {"INDOOR", "INDOOR1", "INDOOR2"}
@@ -682,7 +685,14 @@ class SmartThingsHeatPumpZone(SmartThingsEntity, ClimateEntity):
_attr_name = None
- def __init__(self, client: SmartThings, device: FullDevice, component: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ client: SmartThings,
+ device: FullDevice,
+ component: str,
+ entry_id: str,
+ ) -> None:
"""Init the class."""
super().__init__(
client,
@@ -699,7 +709,11 @@ class SmartThingsHeatPumpZone(SmartThingsEntity, ClimateEntity):
self._attr_hvac_modes = self._determine_hvac_modes()
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{device.device.device_id}_{component}")},
- via_device=(DOMAIN, device.device.device_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, device.device.device_id),
+ config_entry_id=entry_id,
+ ),
name=f"{device.device.label} {component}",
)
diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py
index 442e30d66b28..8965564374f9 100644
--- a/homeassistant/components/solaredge/__init__.py
+++ b/homeassistant/components/solaredge/__init__.py
@@ -8,9 +8,16 @@ from aiosolaredge import SolarEdge
from homeassistant.const import CONF_API_KEY, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import CONF_SITE_ID, DATA_API_CLIENT, DATA_MODULES_COORDINATOR, LOGGER
+from .const import (
+ CONF_SITE_ID,
+ DATA_API_CLIENT,
+ DATA_MODULES_COORDINATOR,
+ DOMAIN,
+ LOGGER,
+)
from .coordinator import SolarEdgeModulesCoordinator
from .types import SolarEdgeConfigEntry, SolarEdgeData
@@ -42,6 +49,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) ->
return False
entry.runtime_data[DATA_API_CLIENT] = api
+
+ # Register the site device up front so per-battery devices can resolve it
+ # as their via_device when their entities are created.
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, site_id)},
+ manufacturer="SolarEdge",
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Setup for username/password (modules statistics)
diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py
index 031b731d8daf..b3860c66d371 100644
--- a/homeassistant/components/solaredge/coordinator.py
+++ b/homeassistant/components/solaredge/coordinator.py
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, override
from aiosolaredge import SolarEdge
-from solaredge_web import EnergyData, SolarEdgeWeb, TimeUnit
+from solaredge_web import EnergyData, SolarEdgeWeb
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -16,6 +16,7 @@ from homeassistant.components.recorder.models import (
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
+ async_list_statistic_ids,
get_last_statistics,
statistics_during_period,
)
@@ -23,7 +24,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import dt as dt_util, snakecase
+from homeassistant.util import dt as dt_util, slugify, snakecase
from homeassistant.util.unit_conversion import EnergyConverter
from .const import (
@@ -455,6 +456,8 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]):
)
self.site_id = config_entry.data[CONF_SITE_ID]
self.title = config_entry.title
+ self._serial_to_legacy_id: dict[str, str] = {}
+ self._legacy_id_map_initialized = False
@callback
def _dummy_listener() -> None:
@@ -467,13 +470,12 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]):
@override
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint and update statistics."""
- equipment: dict[int, dict[str, Any]] = await self.api.async_get_equipment()
+ equipment: dict[str, dict[str, Any]] = await self.api.async_get_equipment()
+ await self._async_build_legacy_id_map(equipment)
# We fetch last week's data from the API and refresh
# every 12h so we overwrite recent statistics. This is
# intended to allow adding any corrected/updated data.
- energy_data_list: list[EnergyData] = await self.api.async_get_energy_data(
- TimeUnit.WEEK
- )
+ energy_data_list: list[EnergyData] = await self.api.async_get_energy_data()
if not energy_data_list:
LOGGER.warning(
"No data received from SolarEdge API for site: %s", self.site_id
@@ -486,9 +488,7 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]):
),
)
for equipment_id, equipment_data in equipment.items():
- display_name = equipment_data.get(
- "displayName", f"Equipment {equipment_id}"
- )
+ display_name = equipment_data.get("name", f"Equipment {equipment_id}")
statistic_id = self.get_statistic_id(equipment_id)
statistic_metadata = StatisticMetaData(
mean_type=StatisticMeanType.ARITHMETIC,
@@ -501,30 +501,19 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]):
)
statistic_sum = last_sums[statistic_id]
statistics = []
- current_hour_sum = 0.0
- current_hour_count = 0
for energy_data in energy_data_list:
start_time = energy_data.start_time.replace(
tzinfo=dt_util.get_default_time_zone()
)
value = energy_data.values.get(equipment_id, 0.0)
- current_hour_sum += value
- current_hour_count += 1
- if start_time.minute != 45:
- continue
- # API returns data every 15 minutes; aggregate to 1-hour statistics
- # when we reach the energy_data for the last 15 minutes of the hour.
- current_avg = current_hour_sum / current_hour_count
- statistic_sum += current_avg
+ statistic_sum += value
statistics.append(
StatisticData(
- start=start_time - timedelta(minutes=45),
- state=current_avg,
+ start=start_time,
+ state=value,
sum=statistic_sum,
)
)
- current_hour_sum = 0.0
- current_hour_count = 0
LOGGER.debug(
"Adding %s statistics for %s %s",
len(statistics),
@@ -533,12 +522,90 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]):
)
async_add_external_statistics(self.hass, statistic_metadata, statistics)
- def get_statistic_id(self, equipment_id: int) -> str:
- """Return the statistic ID for this equipment_id."""
+ async def _async_build_legacy_id_map(
+ self, equipment: dict[str, dict[str, Any]]
+ ) -> None:
+ """Build a map from serial numbers to legacy numeric statistic IDs.
+
+ The old API returned numeric equipment IDs; the new API returns serials.
+ To preserve existing statistics, we map each serial's name (the last
+ part after splitting by space, e.g. "1.1.1") to the old statistic ID
+ by matching the statistic name. This runs once per coordinator lifecycle.
+ """
+ if self._legacy_id_map_initialized:
+ return
+
+ all_stats = await async_list_statistic_ids(self.hass)
+
+ prefix = f"{DOMAIN}:{self.site_id}_"
+ # Map the short name (e.g. "1.1.1") to the old numeric statistic ID.
+ # Multiple numeric IDs may share the same name, e.g. after a module
+ # replacement; those matches are ambiguous and skipped.
+ name_to_legacy: dict[str, str] = {}
+ ambiguous_names: set[str] = set()
+ for stat in all_stats:
+ stat_id = stat["statistic_id"]
+ if not stat_id.startswith(prefix):
+ continue
+ suffix = stat_id[len(prefix) :]
+ if not suffix.isdigit():
+ continue
+ name = stat.get("name", "")
+ # Use the last part after splitting by space (e.g. "solaredge 1.1.1" -> "1.1.1").
+ short_name = name.rsplit(" ", 1)[-1] if name else ""
+ if not short_name:
+ continue
+ if short_name in name_to_legacy:
+ del name_to_legacy[short_name]
+ ambiguous_names.add(short_name)
+ elif short_name not in ambiguous_names:
+ name_to_legacy[short_name] = stat_id
+
+ self._legacy_id_map_initialized = True
+
+ if not name_to_legacy and not ambiguous_names:
+ return
+
+ # Map each serial to its old statistic ID via the equipment name.
+ for serial, data in equipment.items():
+ name = data.get("name", "")
+ short_name = name.rsplit(" ", 1)[-1] if name else ""
+ if not short_name:
+ continue
+ if short_name in ambiguous_names:
+ LOGGER.warning(
+ "Skipping legacy statistics migration for %s %s: multiple "
+ "numeric statistics share the name %s, so the match is "
+ "ambiguous",
+ self.site_id,
+ name,
+ short_name,
+ )
+ continue
+ if short_name in name_to_legacy:
+ self._serial_to_legacy_id[serial] = name_to_legacy[short_name]
+
+ if self._serial_to_legacy_id:
+ LOGGER.debug(
+ "Mapped %s legacy SolarEdge statistics to serials for site %s",
+ len(self._serial_to_legacy_id),
+ self.site_id,
+ )
+
+ def get_statistic_id(self, equipment_id: int | str) -> str:
+ """Return the statistic ID for this equipment_id.
+
+ If a legacy numeric ID exists for this serial, reuse it for backward
+ compatibility. Otherwise, create a new serial-based ID.
+ """
+ if isinstance(equipment_id, str) and equipment_id in self._serial_to_legacy_id:
+ return self._serial_to_legacy_id[equipment_id]
+ if isinstance(equipment_id, str):
+ equipment_id = slugify(equipment_id)
return f"{DOMAIN}:{self.site_id}_{equipment_id}"
async def _async_get_last_sums(
- self, equipment_ids: Iterable[int], start_time: datetime
+ self, equipment_ids: Iterable[int | str], start_time: datetime
) -> dict[str, float]:
"""Get the last sum from the recorder before start_time for each statistic."""
start = start_time - timedelta(hours=1)
diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json
index 276ddaf0c821..ab668d398096 100644
--- a/homeassistant/components/solaredge/manifest.json
+++ b/homeassistant/components/solaredge/manifest.json
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["aiosolaredge", "solaredge_web"],
- "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.0.1"]
+ "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.3.0"]
}
diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py
index 5a9913a31c65..978785af2054 100644
--- a/homeassistant/components/solaredge/sensor.py
+++ b/homeassistant/components/solaredge/sensor.py
@@ -13,6 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
@@ -616,7 +617,11 @@ class SolarEdgeBatterySensor(SolarEdgeSensorEntity):
manufacturer="SolarEdge",
name=f"Battery {serial}",
serial_number=serial,
- via_device=(DOMAIN, data_service.site_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, data_service.site_id),
+ config_entry_id=data_service.config_entry.entry_id,
+ ),
)
@property
diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py
index e6aa88ad45cf..556e0108963e 100644
--- a/homeassistant/components/solarlog/coordinator.py
+++ b/homeassistant/components/solarlog/coordinator.py
@@ -201,10 +201,7 @@ class SolarLogDeviceDataCoordinator(DataUpdateCoordinator[dict[int, InverterData
),
self.config_entry.entry_id,
):
- 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)
_LOGGER.info("Device removed from device registry: %s", device.id)
# add new devices
diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py
index 57d207885f40..7d0041d03f85 100644
--- a/homeassistant/components/subaru/config_flow.py
+++ b/homeassistant/components/subaru/config_flow.py
@@ -1,7 +1,7 @@
"""Config flow for Subaru integration."""
-from datetime import datetime
import logging
+import time
from typing import TYPE_CHECKING, Any, override
from subarulink import (
@@ -23,6 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.util import dt as dt_util
from .const import CONF_UPDATE_ENABLED, DOMAIN
from .coordinator import SubaruConfigEntry
@@ -110,10 +111,9 @@ class SubaruConfigFlow(ConfigFlow, domain=DOMAIN):
data: contains values provided by the user.
"""
websession = aiohttp_client.async_get_clientsession(self.hass)
- now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
if not data.get(CONF_DEVICE_ID):
- data[CONF_DEVICE_ID] = int(now.timestamp())
- date = now.strftime("%Y-%m-%d")
+ data[CONF_DEVICE_ID] = int(time.time())
+ date = dt_util.now().strftime("%Y-%m-%d")
device_name = "Home Assistant: Added " + date
self.controller = SubaruAPI(
diff --git a/homeassistant/components/sunricher_dali/binary_sensor.py b/homeassistant/components/sunricher_dali/binary_sensor.py
index a49deaa4b8a8..6b4a142d0222 100644
--- a/homeassistant/components/sunricher_dali/binary_sensor.py
+++ b/homeassistant/components/sunricher_dali/binary_sensor.py
@@ -11,10 +11,8 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN, MANUFACTURER
from .entity import DaliDeviceEntity
from .types import DaliCenterConfigEntry
@@ -40,8 +38,8 @@ async def async_setup_entry(
entities: list[BinarySensorEntity] = []
for device in devices:
if is_motion_sensor(device.dev_type):
- entities.append(SunricherDaliMotionSensor(device))
- entities.append(SunricherDaliOccupancySensor(device))
+ entities.append(SunricherDaliMotionSensor(hass, device, entry))
+ entities.append(SunricherDaliOccupancySensor(hass, device, entry))
if entities:
async_add_entities(entities)
@@ -52,18 +50,12 @@ class SunricherDaliMotionSensor(DaliDeviceEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOTION
- def __init__(self, device: Device) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry
+ ) -> None:
"""Initialize the motion sensor."""
- super().__init__(device)
- self._device = device
+ super().__init__(hass, device, entry)
self._attr_unique_id = f"{device.dev_id}_motion"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, device.dev_id)},
- name=device.name,
- manufacturer=MANUFACTURER,
- model=device.model,
- via_device=(DOMAIN, device.gw_sn),
- )
@override
async def async_added_to_hass(self) -> None:
@@ -95,18 +87,12 @@ class SunricherDaliOccupancySensor(DaliDeviceEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
- def __init__(self, device: Device) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry
+ ) -> None:
"""Initialize the occupancy sensor."""
- super().__init__(device)
- self._device = device
+ super().__init__(hass, device, entry)
self._attr_unique_id = f"{device.dev_id}_occupancy"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, device.dev_id)},
- name=device.name,
- manufacturer=MANUFACTURER,
- model=device.model,
- via_device=(DOMAIN, device.gw_sn),
- )
@override
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/sunricher_dali/button.py b/homeassistant/components/sunricher_dali/button.py
index f4c2fe35f6f4..7e2c4ca8cdad 100644
--- a/homeassistant/components/sunricher_dali/button.py
+++ b/homeassistant/components/sunricher_dali/button.py
@@ -9,10 +9,8 @@ from PySrDaliGateway.helper import is_light_device
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN, MANUFACTURER
from .entity import DaliDeviceEntity
from .types import DaliCenterConfigEntry
@@ -30,7 +28,7 @@ async def async_setup_entry(
devices = entry.runtime_data.devices
async_add_entities(
- DaliCenterIdentifyButton(device)
+ DaliCenterIdentifyButton(hass, device, entry)
for device in devices
if is_light_device(device.dev_type)
)
@@ -43,18 +41,12 @@ class DaliCenterIdentifyButton(DaliDeviceEntity, ButtonEntity):
_attr_entity_category = EntityCategory.CONFIG
_attr_name = None
- def __init__(self, device: Device) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry
+ ) -> None:
"""Initialize the device identify button."""
- super().__init__(device)
- self._device = device
+ super().__init__(hass, device, entry)
self._attr_unique_id = f"{device.unique_id}_identify"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, device.dev_id)},
- name=device.name,
- manufacturer=MANUFACTURER,
- model=device.model,
- via_device=(DOMAIN, device.gw_sn),
- )
@override
async def async_press(self) -> None:
diff --git a/homeassistant/components/sunricher_dali/entity.py b/homeassistant/components/sunricher_dali/entity.py
index c59182f124a2..f37e875a603d 100644
--- a/homeassistant/components/sunricher_dali/entity.py
+++ b/homeassistant/components/sunricher_dali/entity.py
@@ -5,9 +5,14 @@ from typing import override
from PySrDaliGateway import CallbackEventType, DaliObjectBase, Device
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
+from .const import DOMAIN, MANUFACTURER
+from .types import DaliCenterConfigEntry
+
_LOGGER = logging.getLogger(__name__)
@@ -51,7 +56,19 @@ class DaliCenterEntity(Entity):
class DaliDeviceEntity(DaliCenterEntity):
"""Base entity for DALI Device objects."""
- def __init__(self, device: Device) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry
+ ) -> None:
"""Initialize device entity."""
super().__init__(device)
+ self._device = device
self._attr_available = device.status == "online"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.dev_id)},
+ name=device.name,
+ manufacturer=MANUFACTURER,
+ model=device.model,
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, device.gw_sn), config_entry_id=entry.entry_id
+ ),
+ )
diff --git a/homeassistant/components/sunricher_dali/light.py b/homeassistant/components/sunricher_dali/light.py
index 491d0bfc9312..2ace1a4b2f31 100644
--- a/homeassistant/components/sunricher_dali/light.py
+++ b/homeassistant/components/sunricher_dali/light.py
@@ -16,10 +16,8 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN, MANUFACTURER
from .entity import DaliDeviceEntity
from .types import DaliCenterConfigEntry
@@ -38,7 +36,7 @@ async def async_setup_entry(
devices = runtime_data.devices
async_add_entities(
- DaliCenterLight(device)
+ DaliCenterLight(hass, device, entry)
for device in devices
if is_light_device(device.dev_type)
)
@@ -52,18 +50,12 @@ class DaliCenterLight(DaliDeviceEntity, LightEntity):
_attr_max_color_temp_kelvin = 8000
_white_level: int | None = None
- def __init__(self, light: Device) -> None:
+ def __init__(
+ self, hass: HomeAssistant, light: Device, entry: DaliCenterConfigEntry
+ ) -> None:
"""Initialize the light entity."""
- super().__init__(light)
+ super().__init__(hass, light, entry)
self._light = light
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, light.dev_id)},
- name=light.name,
- manufacturer=MANUFACTURER,
- model=light.model,
- via_device=(DOMAIN, light.gw_sn),
- )
-
self._determine_features()
def _determine_features(self) -> None:
diff --git a/homeassistant/components/sunricher_dali/sensor.py b/homeassistant/components/sunricher_dali/sensor.py
index 5c4258b9b584..6d8417603cdf 100644
--- a/homeassistant/components/sunricher_dali/sensor.py
+++ b/homeassistant/components/sunricher_dali/sensor.py
@@ -14,10 +14,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import LIGHT_LUX, EntityCategory, UnitOfEnergy
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN, MANUFACTURER
from .entity import DaliDeviceEntity
from .types import DaliCenterConfigEntry
@@ -37,9 +35,9 @@ async def async_setup_entry(
entities: list[SensorEntity] = []
for device in devices:
if is_illuminance_sensor(device.dev_type):
- entities.append(SunricherDaliIlluminanceSensor(device))
+ entities.append(SunricherDaliIlluminanceSensor(hass, device, entry))
if is_light_device(device.dev_type):
- entities.append(SunricherDaliEnergySensor(device))
+ entities.append(SunricherDaliEnergySensor(hass, device, entry))
if entities:
async_add_entities(entities)
@@ -53,19 +51,13 @@ class SunricherDaliIlluminanceSensor(DaliDeviceEntity, SensorEntity):
_attr_native_unit_of_measurement = LIGHT_LUX
_attr_name = None
- def __init__(self, device: Device) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry
+ ) -> None:
"""Initialize the illuminance sensor."""
- super().__init__(device)
- self._device = device
+ super().__init__(hass, device, entry)
self._illuminance_value: float | None = None
self._sensor_enabled: bool = True
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, device.dev_id)},
- name=device.name,
- manufacturer=MANUFACTURER,
- model=device.model,
- via_device=(DOMAIN, device.gw_sn),
- )
@property
@override
@@ -132,18 +124,12 @@ class SunricherDaliEnergySensor(DaliDeviceEntity, SensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_suggested_display_precision = 2
- def __init__(self, device: Device) -> None:
+ def __init__(
+ self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry
+ ) -> None:
"""Initialize the energy sensor."""
- super().__init__(device)
- self._device = device
+ super().__init__(hass, device, entry)
self._attr_unique_id = f"{device.unique_id}_energy"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, device.dev_id)},
- name=device.name,
- manufacturer=MANUFACTURER,
- model=device.model,
- via_device=(DOMAIN, device.gw_sn),
- )
@override
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py
index 49e1b663a269..ba4c105a409c 100644
--- a/homeassistant/components/switchbee/entity.py
+++ b/homeassistant/components/switchbee/entity.py
@@ -61,10 +61,6 @@ class SwitchBeeDeviceEntity[_DeviceTypeT: SwitchBeeBaseDevice](
manufacturer=SWITCHBEE_BRAND,
model=coordinator.api.module_display(device.unit_id),
suggested_area=device.zone,
- via_device=(
- DOMAIN,
- f"{coordinator.api.name} ({coordinator.api.unique_id})",
- ),
)
@property
diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py
index bb92880d99b4..2771d86d8e94 100644
--- a/homeassistant/components/tado/entity.py
+++ b/homeassistant/components/tado/entity.py
@@ -2,6 +2,7 @@
import logging
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -28,11 +29,15 @@ class TadoDeviceEntity(TadoCoordinatorEntity):
self._device_info = device_info
self.device_name = device_info["serialNo"]
self.device_id = device_info["shortSerialNo"]
- via_device: tuple[str, str] | None = None
+ via_device_id: str | None = None
if device_info["deviceType"] not in TADO_BRIDGE_MODELS:
for device in coordinator.data["device"].values():
if device["deviceType"] in TADO_BRIDGE_MODELS:
- via_device = (DOMAIN, device["shortSerialNo"])
+ via_device_id = dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, device["shortSerialNo"]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ )
break
self._attr_device_info = DeviceInfo(
@@ -43,8 +48,8 @@ class TadoDeviceEntity(TadoCoordinatorEntity):
sw_version=device_info["currentFwVersion"],
model=device_info["deviceType"],
)
- if via_device:
- self._attr_device_info["via_device"] = via_device
+ if via_device_id:
+ self._attr_device_info["via_device_id"] = via_device_id
class TadoHomeEntity(TadoCoordinatorEntity):
diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py
index 501b9844b003..9e6153c996f4 100644
--- a/homeassistant/components/tasmota/__init__.py
+++ b/homeassistant/components/tasmota/__init__.py
@@ -122,9 +122,7 @@ async def _remove_device(
return
_LOGGER.debug("Removing tasmota from device %s", mac)
- device_registry.async_update_device(
- device.id, remove_config_entry_id=config_entry.entry_id
- )
+ device_registry.async_remove_device(device.id)
def _update_device(
diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py
index ba194594ab8f..b360f7b53f53 100644
--- a/homeassistant/components/tedee/coordinator.py
+++ b/homeassistant/components/tedee/coordinator.py
@@ -151,10 +151,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
if device := device_registry.async_get_device_by_identifier(
(DOMAIN, str(lock_id)), self.config_entry.entry_id
):
- 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)
# add new locks
if new_locks := current_locks - self._locks_last_update:
diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py
index e95cf2de681e..1eb9f4dc513e 100644
--- a/homeassistant/components/telegram_bot/notify.py
+++ b/homeassistant/components/telegram_bot/notify.py
@@ -9,6 +9,7 @@ from homeassistant.components.notify import (
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TelegramBotConfigEntry
@@ -51,7 +52,11 @@ class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity):
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}")
+ device_info["via_device_id"] = dr.async_get_device_id_by_identifier(
+ config_entry.runtime_data.hass,
+ (DOMAIN, f"{self.bot_id}"),
+ config_entry_id=config_entry.entry_id,
+ )
@override
async def async_send_message(self, message: str, title: str | None = None) -> None:
diff --git a/homeassistant/components/teltonika/config_flow.py b/homeassistant/components/teltonika/config_flow.py
index 6b57e578bb85..5ea13a6c6b5e 100644
--- a/homeassistant/components/teltonika/config_flow.py
+++ b/homeassistant/components/teltonika/config_flow.py
@@ -235,18 +235,17 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN):
# unauthorized endpoint. Match existing entries by MAC so it
# aborts without asking for credentials again.
device_reg = dr.async_get(self.hass)
- if existing := device_reg.async_get_device(
+ for device in device_reg.async_get_devices(
connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}
):
- for entry_id in existing.config_entries:
- entry = self.hass.config_entries.async_get_entry(entry_id)
- if (
- entry is not None
- and entry.domain == DOMAIN
- and entry.unique_id is not None
- ):
- device_id = entry.unique_id
- break
+ entry = self.hass.config_entries.async_get_entry(device.config_entry_id)
+ if (
+ entry is not None
+ and entry.domain == DOMAIN
+ and entry.unique_id is not None
+ ):
+ device_id = entry.unique_id
+ break
# Use the MAC as a placeholder unique_id when nothing matched, so
# parallel DHCP advertisements don't both reach dhcp_confirm.
diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py
index 30042482cb1b..fe90bc513e8a 100644
--- a/homeassistant/components/tessie/__init__.py
+++ b/homeassistant/components/tessie/__init__.py
@@ -26,6 +26,7 @@ from homeassistant.exceptions import (
ConfigEntryError,
ConfigEntryNotReady,
)
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
@@ -212,6 +213,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
)
entry.runtime_data = TessieData(vehicles, energysites)
+
+ # Register energy site devices before forwarding to platforms so wall
+ # connector entities can resolve their via_device parent at construction.
+ device_registry = dr.async_get(hass)
+ for energysite in energysites:
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id, **energysite.device
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py
index 9b40d15b1812..ad10278f8da4 100644
--- a/homeassistant/components/tessie/entity.py
+++ b/homeassistant/components/tessie/entity.py
@@ -5,6 +5,7 @@ from collections.abc import Awaitable, Callable
from inspect import isawaitable
from typing import Any, override
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -167,14 +168,18 @@ class TessieWallConnectorEntity(TessieBaseEntity):
"""Initialize common aspects of a Teslemetry entity."""
self.din = din
self._attr_unique_id = f"{data.id}-{din}-{key}"
+ assert data.live_coordinator
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, din)},
manufacturer="Tesla",
name="Wall Connector",
- via_device=(DOMAIN, str(data.id)),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ data.live_coordinator.hass,
+ (DOMAIN, str(data.id)),
+ config_entry_id=data.live_coordinator.config_entry.entry_id,
+ ),
serial_number=din.rsplit("-", maxsplit=1)[-1],
)
- assert data.live_coordinator
super().__init__(data.live_coordinator, key, data_key)
@property
diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py
index 10d1e5a0bcb6..212aa2e28bbe 100644
--- a/homeassistant/components/toon/__init__.py
+++ b/homeassistant/components/toon/__init__.py
@@ -113,20 +113,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: ToonConfigEntry) -> bool
entry.runtime_data = coordinator
- # Register device for the Meter Adapter, since it will have no entities.
+ agreement = coordinator.data.agreement
+ agreement_id = agreement.agreement_id
+
+ # Register the parent devices before forwarding the platforms, so that the
+ # child devices created by the entities can deterministically resolve their
+ # via_device_id.
device_registry = dr.async_get(hass)
- device_registry.async_get_or_create(
+ display_device = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, agreement_id)},
+ manufacturer="Eneco",
+ model=agreement.display_hardware_version.rpartition("/")[0],
+ name="Toon Display",
+ sw_version=agreement.display_software_version.rpartition("/")[-1],
+ )
+ # The Meter Adapter has no entities of its own.
+ meter_adapter_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={
- (
- DOMAIN,
- coordinator.data.agreement.agreement_id,
- "meter_adapter",
- ) # type: ignore[arg-type]
+ (DOMAIN, agreement_id, "meter_adapter"), # type: ignore[arg-type]
},
manufacturer="Eneco",
name="Meter Adapter",
- via_device=(DOMAIN, coordinator.data.agreement.agreement_id),
+ via_device_id=display_device.id,
+ )
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={
+ (DOMAIN, agreement_id, "electricity"), # type: ignore[arg-type]
+ },
+ name="Electricity Meter",
+ via_device_id=meter_adapter_device.id,
+ )
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={
+ (DOMAIN, agreement_id, "boiler_module"), # type: ignore[arg-type]
+ },
+ manufacturer="Eneco",
+ name="Boiler Module",
+ via_device_id=display_device.id,
)
# Spin up the platforms
diff --git a/homeassistant/components/toon/entity.py b/homeassistant/components/toon/entity.py
index 9a7f90cc7733..edecd0ba18b8 100644
--- a/homeassistant/components/toon/entity.py
+++ b/homeassistant/components/toon/entity.py
@@ -3,6 +3,7 @@
from dataclasses import dataclass
from typing import override
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -44,10 +45,10 @@ class ToonElectricityMeterDeviceEntity(ToonEntity):
identifiers={
(DOMAIN, agreement_id, "electricity"), # type: ignore[arg-type]
},
- via_device=(
- DOMAIN,
- agreement_id, # type: ignore[typeddict-item]
- "meter_adapter",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, agreement_id, "meter_adapter"), # type: ignore[arg-type]
+ config_entry_id=self.coordinator.config_entry.entry_id,
),
)
@@ -65,10 +66,10 @@ class ToonGasMeterDeviceEntity(ToonEntity):
identifiers={
(DOMAIN, agreement_id, "gas"), # type: ignore[arg-type]
},
- via_device=(
- DOMAIN,
- agreement_id, # type: ignore[typeddict-item]
- "electricity",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, agreement_id, "electricity"), # type: ignore[arg-type]
+ config_entry_id=self.coordinator.config_entry.entry_id,
),
)
@@ -86,10 +87,10 @@ class ToonWaterMeterDeviceEntity(ToonEntity):
identifiers={
(DOMAIN, agreement_id, "water"), # type: ignore[arg-type]
},
- via_device=(
- DOMAIN,
- agreement_id, # type: ignore[typeddict-item]
- "electricity",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, agreement_id, "electricity"), # type: ignore[arg-type]
+ config_entry_id=self.coordinator.config_entry.entry_id,
),
)
@@ -107,10 +108,10 @@ class ToonSolarDeviceEntity(ToonEntity):
identifiers={
(DOMAIN, agreement_id, "solar"), # type: ignore[arg-type]
},
- via_device=(
- DOMAIN,
- agreement_id, # type: ignore[typeddict-item]
- "meter_adapter",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, agreement_id, "meter_adapter"), # type: ignore[arg-type]
+ config_entry_id=self.coordinator.config_entry.entry_id,
),
)
@@ -133,7 +134,11 @@ class ToonBoilerModuleDeviceEntity(ToonEntity):
"boiler_module",
)
},
- via_device=(DOMAIN, agreement_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, agreement_id),
+ config_entry_id=self.coordinator.config_entry.entry_id,
+ ),
)
@@ -150,10 +155,10 @@ class ToonBoilerDeviceEntity(ToonEntity):
identifiers={
(DOMAIN, agreement_id, "boiler"), # type: ignore[arg-type]
},
- via_device=(
- DOMAIN,
- agreement_id, # type: ignore[typeddict-item]
- "boiler_module",
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.coordinator.hass,
+ (DOMAIN, agreement_id, "boiler_module"), # type: ignore[arg-type]
+ config_entry_id=self.coordinator.config_entry.entry_id,
),
)
diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py
index 5b326c4287a2..899c49f5cbba 100644
--- a/homeassistant/components/tplink/coordinator.py
+++ b/homeassistant/components/tplink/coordinator.py
@@ -110,10 +110,7 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
(DOMAIN, device_id), self.config_entry.entry_id
)
if device:
- 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)
child_coordinator = self._child_coordinators.pop(device_id, None)
if child_coordinator:
await child_coordinator.async_shutdown()
diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py
index 896021bc6a44..b0c65eea9185 100644
--- a/homeassistant/components/tractive/__init__.py
+++ b/homeassistant/components/tractive/__init__.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -120,6 +120,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) ->
entry.runtime_data = TractiveData(tractive, filtered_trackables)
+ # Register the tracker devices so entities on the pet devices can resolve
+ # their via_device link at construction time.
+ device_registry = dr.async_get(hass)
+ for item in filtered_trackables:
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ configuration_url="https://my.tractive.com/",
+ identifiers={(DOMAIN, item.tracker_details["_id"])},
+ translation_key="tracker",
+ translation_placeholders={"id": item.tracker_details["_id"]},
+ manufacturer="Tractive GmbH",
+ sw_version=item.tracker_details["fw_version"],
+ model_id=item.tracker_details["model_number"],
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Send initial health overview data to sensors after platforms are set up
diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py
index 3c8ec3bf295a..8ab11a4cb27c 100644
--- a/homeassistant/components/tractive/binary_sensor.py
+++ b/homeassistant/components/tractive/binary_sensor.py
@@ -23,12 +23,16 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
+ entry: TractiveConfigEntry,
client: TractiveClient,
item: Trackables,
description: TractiveBinarySensorEntityDescription,
) -> None:
"""Initialize sensor entity."""
super().__init__(
+ hass,
+ entry,
client,
item.trackable,
item.tracker_details,
@@ -80,7 +84,7 @@ async def async_setup_entry(
trackables = entry.runtime_data.trackables
entities = [
- TractiveBinarySensor(client, item, description)
+ TractiveBinarySensor(hass, entry, client, item, description)
for description in SENSOR_TYPES
for item in trackables
if description.supported(item.tracker_details)
diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py
index 4322260f3353..0b3880152e96 100644
--- a/homeassistant/components/tractive/device_tracker.py
+++ b/homeassistant/components/tractive/device_tracker.py
@@ -21,7 +21,7 @@ async def async_setup_entry(
client = entry.runtime_data.client
trackables = entry.runtime_data.trackables
- entities = [TractiveDeviceTracker(client, item) for item in trackables]
+ entities = [TractiveDeviceTracker(hass, entry, client, item) for item in trackables]
async_add_entities(entities)
@@ -32,9 +32,17 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity):
_attr_translation_key = "tracker"
_attr_name = None
- def __init__(self, client: TractiveClient, item: Trackables) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: TractiveConfigEntry,
+ client: TractiveClient,
+ item: Trackables,
+ ) -> None:
"""Initialize tracker entity."""
super().__init__(
+ hass,
+ entry,
client,
item.trackable,
item.tracker_details,
diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py
index 973d7037a4ef..9186b1df56a9 100644
--- a/homeassistant/components/tractive/entity.py
+++ b/homeassistant/components/tractive/entity.py
@@ -2,12 +2,13 @@
from typing import Any, override
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from . import TractiveClient
+from . import TractiveClient, TractiveConfigEntry
from .const import DOMAIN, SERVER_UNAVAILABLE
@@ -18,6 +19,8 @@ class TractiveEntity(Entity):
def __init__(
self,
+ hass: HomeAssistant,
+ entry: TractiveConfigEntry,
client: TractiveClient,
trackable: dict[str, Any],
tracker_details: dict[str, Any],
@@ -39,7 +42,11 @@ class TractiveEntity(Entity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, trackable["_id"])},
name=trackable["details"]["name"],
- via_device=(DOMAIN, tracker_details["_id"]),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, tracker_details["_id"]),
+ config_entry_id=entry.entry_id,
+ ),
entry_type=DeviceEntryType.SERVICE,
)
diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py
index dc4c6a22d0fd..ccb5a2238014 100644
--- a/homeassistant/components/tractive/sensor.py
+++ b/homeassistant/components/tractive/sensor.py
@@ -51,6 +51,8 @@ class TractiveSensor(TractiveEntity, SensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
+ entry: TractiveConfigEntry,
client: TractiveClient,
item: Trackables,
description: TractiveSensorEntityDescription,
@@ -63,6 +65,8 @@ class TractiveSensor(TractiveEntity, SensorEntity):
else:
dispatcher_signal = f"{description.signal_prefix}-{item.trackable['_id']}"
super().__init__(
+ hass,
+ entry,
client,
item.trackable,
item.tracker_details,
@@ -156,7 +160,7 @@ async def async_setup_entry(
trackables = entry.runtime_data.trackables
entities = [
- TractiveSensor(client, item, description)
+ TractiveSensor(hass, entry, client, item, description)
for description in SENSOR_TYPES
for item in trackables
]
diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py
index e8884c301821..d75f29f18fd5 100644
--- a/homeassistant/components/tractive/switch.py
+++ b/homeassistant/components/tractive/switch.py
@@ -65,7 +65,7 @@ async def async_setup_entry(
trackables = entry.runtime_data.trackables
entities = [
- TractiveSwitch(client, item, description)
+ TractiveSwitch(hass, entry, client, item, description)
for description in SWITCH_TYPES
for item in trackables
]
@@ -80,12 +80,16 @@ class TractiveSwitch(TractiveEntity, SwitchEntity):
def __init__(
self,
+ hass: HomeAssistant,
+ entry: TractiveConfigEntry,
client: TractiveClient,
item: Trackables,
description: TractiveSwitchEntityDescription,
) -> None:
"""Initialize switch entity."""
super().__init__(
+ hass,
+ entry,
client,
item.trackable,
item.tracker_details,
diff --git a/homeassistant/components/trmnl/coordinator.py b/homeassistant/components/trmnl/coordinator.py
index e4aa2b93520b..13c144c9a071 100644
--- a/homeassistant/components/trmnl/coordinator.py
+++ b/homeassistant/components/trmnl/coordinator.py
@@ -62,8 +62,5 @@ class TRMNLCoordinator(DataUpdateCoordinator[dict[int, Device]]):
if entry := device_registry.async_get_device_by_identifier(
(DOMAIN, str(device_id)), self.config_entry.entry_id
):
- device_registry.async_update_device(
- device_id=entry.id,
- remove_config_entry_id=self.config_entry.entry_id,
- )
+ device_registry.async_remove_device(entry.id)
return new_data
diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json
index d61a2628f646..c735db8ced57 100644
--- a/homeassistant/components/tuya/manifest.json
+++ b/homeassistant/components/tuya/manifest.json
@@ -45,6 +45,6 @@
"loggers": ["tuya_sharing"],
"requirements": [
"tuya-device-handlers==0.0.26",
- "tuya-device-sharing-sdk==0.2.10"
+ "tuya-device-sharing-sdk==0.2.14"
]
}
diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py
index 6eb877fe1f43..1c1c40c5f073 100644
--- a/homeassistant/components/unifiprotect/__init__.py
+++ b/homeassistant/components/unifiprotect/__init__.py
@@ -192,7 +192,7 @@ async def _async_setup_entry(
# This ensures via_device references work for all device entities
nvr = bootstrap.nvr
device_registry = dr.async_get(hass)
- device_registry.async_get_or_create(
+ nvr_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, nvr.mac)},
identifiers={(DOMAIN, nvr.mac)},
@@ -202,6 +202,7 @@ async def _async_setup_entry(
sw_version=str(nvr.version),
configuration_url=nvr.api.base_url,
)
+ data_service.nvr_device_id = nvr_device.id
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
hass.http.register_view(ThumbnailProxyView(hass))
diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py
index 182f50c8e505..a9760ecf0f96 100644
--- a/homeassistant/components/unifiprotect/data.py
+++ b/homeassistant/components/unifiprotect/data.py
@@ -72,6 +72,9 @@ def _async_dispatch_id(entry: UFPConfigEntry, dispatch: str) -> str:
class ProtectData:
"""Coordinate updates."""
+ # Resolved once in _async_setup_entry, before any entity is created.
+ nvr_device_id: str
+
def __init__(
self,
hass: HomeAssistant,
diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py
index 9508de8a3b1d..c814336aa1e0 100644
--- a/homeassistant/components/unifiprotect/entity.py
+++ b/homeassistant/components/unifiprotect/entity.py
@@ -416,7 +416,7 @@ class ProtectDeviceEntity(BaseProtectEntity):
manufacturer=DEFAULT_BRAND,
model=self.device.market_name or self.device.type,
model_id=self.device.type,
- via_device=(DOMAIN, self.data.api.bootstrap.nvr.mac),
+ via_device_id=self.data.nvr_device_id,
sw_version=self.device.firmware_version,
connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)},
configuration_url=self.device.protect_url,
diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py
index 1ea88028e80e..4256c022f32c 100644
--- a/homeassistant/components/unifiprotect/siren.py
+++ b/homeassistant/components/unifiprotect/siren.py
@@ -68,14 +68,13 @@ class ProtectSiren(SirenEntity):
self.data = data
self._siren_id = siren.id
self._attr_unique_id = f"{siren.mac}_siren" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
- nvr = data.api.bootstrap.nvr
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, siren.mac)},
identifiers={(DOMAIN, siren.mac)},
manufacturer=DEFAULT_BRAND,
name=siren.name,
model="Siren",
- via_device=(DOMAIN, nvr.mac),
+ via_device_id=data.nvr_device_id,
)
self._siren_mac = siren.mac
self._cancel_scheduled_off: CALLBACK_TYPE | None = None
diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py
index 54812f75882e..b6cd391a63a7 100644
--- a/homeassistant/components/unifiprotect/switch.py
+++ b/homeassistant/components/unifiprotect/switch.py
@@ -615,14 +615,13 @@ class ProtectRelayOutputSwitch(SwitchEntity):
self._attr_translation_placeholders = {
"output_name": output.name or str(output.id),
}
- nvr = data.api.bootstrap.nvr
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, relay.mac)},
identifiers={(DOMAIN, relay.mac)},
manufacturer=DEFAULT_BRAND,
name=relay.name,
model="Relay",
- via_device=(DOMAIN, nvr.mac),
+ via_device_id=data.nvr_device_id,
)
self._update_from_relay(relay)
diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py
index a85d23e1178f..da7543089a86 100644
--- a/homeassistant/components/uptimerobot/coordinator.py
+++ b/homeassistant/components/uptimerobot/coordinator.py
@@ -72,9 +72,6 @@ class UptimeRobotDataUpdateCoordinator(
if device := device_registry.async_get_device_by_identifier(
(DOMAIN, str(monitor_id)), self.config_entry.entry_id
):
- 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)
return new_monitors
diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py
index d6bacaa242ba..f7374caa504f 100644
--- a/homeassistant/components/velbus/entity.py
+++ b/homeassistant/components/velbus/entity.py
@@ -2,12 +2,13 @@
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
-from typing import Any, Concatenate, override
+from typing import TYPE_CHECKING, Any, Concatenate, override
from velbusaio.channels import Channel as VelbusChannel
from velbusaio.properties import Property as VelbusProperty
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -31,22 +32,6 @@ class VelbusEntity(Entity):
self._channel = channel
self._module_address = str(channel.get_module_address())
self._attr_name = channel.get_name()
- self._attr_device_info = DeviceInfo(
- identifiers={
- (DOMAIN, self._get_identifier()),
- },
- manufacturer="Velleman",
- model=channel.get_module_type_name(),
- model_id=str(channel.get_module_type()),
- name=channel.get_full_name(),
- sw_version=channel.get_module_sw_version(),
- serial_number=channel.get_module_serial(),
- )
- if self._channel.is_sub_device():
- self._attr_device_info["via_device"] = (
- DOMAIN,
- self._module_address,
- )
serial = channel.get_module_serial() or self._module_address
self._attr_unique_id = f"{serial}-{channel.get_channel_number()}"
@@ -56,6 +41,31 @@ class VelbusEntity(Entity):
return self._module_address
return f"{self._module_address}-{self._channel.get_channel_number()}"
+ @property
+ @override
+ def device_info(self) -> DeviceInfo:
+ """Return device info, linking a sub-device to its module device."""
+ channel = self._channel
+ device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._get_identifier())},
+ manufacturer="Velleman",
+ model=channel.get_module_type_name(),
+ model_id=str(channel.get_module_type()),
+ name=channel.get_full_name(),
+ sw_version=channel.get_module_sw_version(),
+ serial_number=channel.get_module_serial(),
+ )
+ if channel.is_sub_device():
+ config_entry = self.platform.config_entry
+ if TYPE_CHECKING:
+ assert config_entry
+ device_info["via_device_id"] = dr.async_get_device_id_by_identifier(
+ self.hass,
+ (DOMAIN, self._module_address),
+ config_entry_id=config_entry.entry_id,
+ )
+ return device_info
+
@override
async def async_added_to_hass(self) -> None:
"""Add listener for state changes."""
diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py
index 6216e75aa970..571552165719 100644
--- a/homeassistant/components/velux/binary_sensor.py
+++ b/homeassistant/components/velux/binary_sensor.py
@@ -53,7 +53,9 @@ class VeluxRainSensor(
node = coordinator.node
unique_id = velux_unique_id(node, config_entry_id)
self._attr_unique_id = f"{unique_id}_rain_sensor"
- self._attr_device_info = velux_device_info(node, config_entry_id)
+ self._attr_device_info = velux_device_info(
+ self.coordinator.hass, node, config_entry_id
+ )
@override
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/velux/button.py b/homeassistant/components/velux/button.py
index 7ce2329573cc..9a50cff51864 100644
--- a/homeassistant/components/velux/button.py
+++ b/homeassistant/components/velux/button.py
@@ -29,7 +29,7 @@ async def async_setup_entry(
VeluxGatewayRebootButton(config_entry.entry_id, pyvlx)
]
entities.extend(
- VeluxIdentifyButton(node, config_entry.entry_id)
+ VeluxIdentifyButton(hass, node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, Node)
)
@@ -42,9 +42,9 @@ class VeluxIdentifyButton(VeluxEntity, ButtonEntity):
_attr_device_class = ButtonDeviceClass.IDENTIFY
_attr_entity_category = EntityCategory.DIAGNOSTIC
- def __init__(self, node: Node, config_entry_id: str) -> None:
+ def __init__(self, hass: HomeAssistant, node: Node, config_entry_id: str) -> None:
"""Initialize the Velux identify button."""
- super().__init__(node, config_entry_id)
+ super().__init__(hass, node, config_entry_id)
self._attr_unique_id = f"{self._attr_unique_id}_identify"
@wrap_pyvlx_call_exceptions
diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py
index e3a65660f554..cec443630cef 100644
--- a/homeassistant/components/velux/cover.py
+++ b/homeassistant/components/velux/cover.py
@@ -42,26 +42,26 @@ async def async_setup_entry(
entities: list[VeluxCover] = []
for node in pyvlx.nodes:
if isinstance(node, Blind):
- entities.append(VeluxBlind(node, config_entry.entry_id))
+ entities.append(VeluxBlind(hass, node, config_entry.entry_id))
elif isinstance(node, DualRollerShutter):
# add three entities, one for each part and the "dual" control
entities.append(
VeluxDualRollerShutter(
- node, config_entry.entry_id, VeluxDualRollerPart.DUAL
+ hass, node, config_entry.entry_id, VeluxDualRollerPart.DUAL
)
)
entities.append(
VeluxDualRollerShutter(
- node, config_entry.entry_id, VeluxDualRollerPart.UPPER
+ hass, node, config_entry.entry_id, VeluxDualRollerPart.UPPER
)
)
entities.append(
VeluxDualRollerShutter(
- node, config_entry.entry_id, VeluxDualRollerPart.LOWER
+ hass, node, config_entry.entry_id, VeluxDualRollerPart.LOWER
)
)
elif isinstance(node, OpeningDevice):
- entities.append(VeluxCover(node, config_entry.entry_id))
+ entities.append(VeluxCover(hass, node, config_entry.entry_id))
async_add_entities(entities)
@@ -79,9 +79,11 @@ class VeluxCover(VeluxEntity, CoverEntity):
| CoverEntityFeature.STOP
)
- def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, node: OpeningDevice, config_entry_id: str
+ ) -> None:
"""Initialize VeluxCover."""
- super().__init__(node, config_entry_id)
+ super().__init__(hass, node, config_entry_id)
match node:
case Window():
self._attr_device_class = CoverDeviceClass.WINDOW
@@ -166,10 +168,14 @@ class VeluxDualRollerShutter(VeluxCover):
_attr_device_class = CoverDeviceClass.SHUTTER
def __init__(
- self, node: DualRollerShutter, config_entry_id: str, part: VeluxDualRollerPart
+ self,
+ hass: HomeAssistant,
+ node: DualRollerShutter,
+ config_entry_id: str,
+ part: VeluxDualRollerPart,
) -> None:
"""Initialize VeluxDualRollerShutter."""
- super().__init__(node, config_entry_id)
+ super().__init__(hass, node, config_entry_id)
if part == VeluxDualRollerPart.DUAL:
self._attr_name = None
else:
@@ -235,9 +241,9 @@ class VeluxBlind(VeluxCover):
node: Blind
_attr_device_class = CoverDeviceClass.BLIND
- def __init__(self, node: Blind, config_entry_id: str) -> None:
+ def __init__(self, hass: HomeAssistant, node: Blind, config_entry_id: str) -> None:
"""Initialize VeluxBlind."""
- super().__init__(node, config_entry_id)
+ super().__init__(hass, node, config_entry_id)
self._attr_supported_features |= (
CoverEntityFeature.OPEN_TILT
diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py
index 0959d22a83a4..84c832b48e30 100644
--- a/homeassistant/components/velux/entity.py
+++ b/homeassistant/components/velux/entity.py
@@ -7,7 +7,9 @@ from typing import Any, override
from pyvlx import Node, PyVLXException
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -21,14 +23,20 @@ def velux_unique_id(node: Node, config_entry_id: str) -> str:
return node.serial_number or f"{config_entry_id}_{node.node_id}"
-def velux_device_info(node: Node, config_entry_id: str) -> DeviceInfo:
+def velux_device_info(
+ hass: HomeAssistant, node: Node, config_entry_id: str
+) -> DeviceInfo:
"""Build DeviceInfo for a Velux node."""
unique_id = velux_unique_id(node, config_entry_id)
return DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=node.name or f"#{node.node_id}",
serial_number=node.serial_number,
- via_device=(DOMAIN, f"gateway_{config_entry_id}"),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass,
+ (DOMAIN, f"gateway_{config_entry_id}"),
+ config_entry_id=config_entry_id,
+ ),
)
@@ -65,11 +73,11 @@ class VeluxEntity(Entity):
_attr_available = True
_unavailable_logged = False
- def __init__(self, node: Node, config_entry_id: str) -> None:
+ def __init__(self, hass: HomeAssistant, 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)
+ self._attr_device_info = velux_device_info(hass, node, config_entry_id)
async def after_update_callback(self, _: Node) -> None:
"""Call after device was updated."""
diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py
index 67afe3ae12c0..197f7385da98 100644
--- a/homeassistant/components/velux/light.py
+++ b/homeassistant/components/velux/light.py
@@ -24,9 +24,9 @@ async def async_setup_entry(
entities: list[VeluxOnOffLight] = []
for node in pyvlx.nodes:
if isinstance(node, Light):
- entities.append(VeluxDimmableLight(node, config_entry.entry_id))
+ entities.append(VeluxDimmableLight(hass, node, config_entry.entry_id))
elif isinstance(node, OnOffLight):
- entities.append(VeluxOnOffLight(node, config_entry.entry_id))
+ entities.append(VeluxOnOffLight(hass, node, config_entry.entry_id))
async_add_entities(entities)
diff --git a/homeassistant/components/velux/number.py b/homeassistant/components/velux/number.py
index e026ef004883..290071579f7e 100644
--- a/homeassistant/components/velux/number.py
+++ b/homeassistant/components/velux/number.py
@@ -32,7 +32,7 @@ async def async_setup_entry(
pyvlx = config_entry.runtime_data.pyvlx
limitation_coordinators = config_entry.runtime_data.limitation_coordinators
entities: list[NumberEntity] = [
- VeluxExteriorHeatingNumber(node, config_entry.entry_id)
+ VeluxExteriorHeatingNumber(hass, node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, ExteriorHeating)
]
@@ -105,7 +105,9 @@ class VeluxPositionLimitNumber(
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)
+ self._attr_device_info = velux_device_info(
+ self.coordinator.hass, node, config_entry_id
+ )
@override
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/velux/switch.py b/homeassistant/components/velux/switch.py
index 8cd8ba459704..51d2362af7fc 100644
--- a/homeassistant/components/velux/switch.py
+++ b/homeassistant/components/velux/switch.py
@@ -22,7 +22,7 @@ async def async_setup_entry(
"""Set up switch(es) for Velux platform."""
pyvlx = config_entry.runtime_data.pyvlx
async_add_entities(
- VeluxOnOffSwitch(node, config_entry.entry_id)
+ VeluxOnOffSwitch(hass, node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, OnOffSwitch)
)
diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py
index 62b34130a444..ff890ffd9f0d 100644
--- a/homeassistant/components/verisure/__init__.py
+++ b/homeassistant/components/verisure/__init__.py
@@ -9,10 +9,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.storage import STORAGE_DIR
-from .const import CONF_LOCK_DEFAULT_CODE, LOGGER
+from .const import CONF_GIID, CONF_LOCK_DEFAULT_CODE, DOMAIN, LOGGER
from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator
PLATFORMS = [
@@ -38,7 +38,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: VerisureConfigEntry) ->
entry.runtime_data = coordinator
- # Migrate lock default code from config entry to lock entity
+ # Register the alarm (VBox) device so children can link to it via via_device_id
+ # regardless of platform setup order.
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, entry.data[CONF_GIID])},
+ manufacturer="Verisure",
+ model="VBox",
+ name="Verisure Alarm",
+ configuration_url="https://mypages.verisure.com",
+ )
# Set up all platforms for this device/entry.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py
index 9d8fd350ffe5..34d4af7b9a09 100644
--- a/homeassistant/components/verisure/binary_sensor.py
+++ b/homeassistant/components/verisure/binary_sensor.py
@@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -51,18 +52,17 @@ class VerisureDoorWindowSensor(
super().__init__(coordinator)
self._attr_unique_id = f"{serial_number}_door_window"
self.serial_number = serial_number
-
- @property
- @override
- def device_info(self) -> DeviceInfo:
- """Return device information about this entity."""
- area = self.coordinator.data["door_window"][self.serial_number]["area"]
- return DeviceInfo(
+ area = coordinator.data["door_window"][serial_number]["area"]
+ self._attr_device_info = DeviceInfo(
name=area,
manufacturer="Verisure",
model="Shock Sensor Detector",
- identifiers={(DOMAIN, self.serial_number)},
- via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]),
+ identifiers={(DOMAIN, serial_number)},
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.data[CONF_GIID]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
configuration_url="https://mypages.verisure.com",
)
diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py
index dd59ae36a16a..a7d11e5da57b 100644
--- a/homeassistant/components/verisure/camera.py
+++ b/homeassistant/components/verisure/camera.py
@@ -9,6 +9,7 @@ from verisure import Error as VerisureError
from homeassistant.components.camera import Camera
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
@@ -63,18 +64,17 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera)
self._directory_path = directory_path
self._image: str | None = None
self._image_id: str | None = None
-
- @property
- @override
- def device_info(self) -> DeviceInfo:
- """Return device information about this entity."""
- area = self.coordinator.data["cameras"][self.serial_number]["device"]["area"]
- return DeviceInfo(
+ area = coordinator.data["cameras"][serial_number]["device"]["area"]
+ self._attr_device_info = DeviceInfo(
name=area,
manufacturer="Verisure",
model="SmartCam",
- identifiers={(DOMAIN, self.serial_number)},
- via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]),
+ identifiers={(DOMAIN, serial_number)},
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.data[CONF_GIID]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
configuration_url="https://mypages.verisure.com",
)
diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py
index 54187011ce81..f1dfc81d214d 100644
--- a/homeassistant/components/verisure/lock.py
+++ b/homeassistant/components/verisure/lock.py
@@ -8,6 +8,7 @@ from verisure import Error as VerisureError
from homeassistant.components.lock import LockEntity, LockState
from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
@@ -70,18 +71,17 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt
self._attr_is_locked = None
self._attr_changed_by = None
self._changed_method: str | None = None
-
- @property
- @override
- def device_info(self) -> DeviceInfo:
- """Return device information about this entity."""
- area = self.coordinator.data["locks"][self.serial_number]["device"]["area"]
- return DeviceInfo(
+ area = coordinator.data["locks"][serial_number]["device"]["area"]
+ self._attr_device_info = DeviceInfo(
name=area,
manufacturer="Verisure",
model="Lockguard Smartlock",
- identifiers={(DOMAIN, self.serial_number)},
- via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]),
+ identifiers={(DOMAIN, serial_number)},
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.data[CONF_GIID]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
configuration_url="https://mypages.verisure.com",
)
diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py
index b15dbcbe4557..ce98670d0a74 100644
--- a/homeassistant/components/verisure/sensor.py
+++ b/homeassistant/components/verisure/sensor.py
@@ -9,6 +9,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -58,21 +59,18 @@ class VerisureThermometer(
super().__init__(coordinator)
self._attr_unique_id = f"{serial_number}_temperature"
self.serial_number = serial_number
-
- @property
- @override
- def device_info(self) -> DeviceInfo:
- """Return device information about this entity."""
- device_type = self.coordinator.data["climate"][self.serial_number]["device"][
- "gui"
- ]["label"]
- area = self.coordinator.data["climate"][self.serial_number]["device"]["area"]
- return DeviceInfo(
- name=area,
+ device = coordinator.data["climate"][serial_number]["device"]
+ device_type = device["gui"]["label"]
+ self._attr_device_info = DeviceInfo(
+ name=device["area"],
manufacturer="Verisure",
model=DEVICE_TYPE_NAME.get(device_type, device_type),
- identifiers={(DOMAIN, self.serial_number)},
- via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]),
+ identifiers={(DOMAIN, serial_number)},
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.data[CONF_GIID]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
configuration_url="https://mypages.verisure.com",
)
@@ -111,21 +109,18 @@ class VerisureHygrometer(
super().__init__(coordinator)
self._attr_unique_id = f"{serial_number}_humidity"
self.serial_number = serial_number
-
- @property
- @override
- def device_info(self) -> DeviceInfo:
- """Return device information about this entity."""
- device_type = self.coordinator.data["climate"][self.serial_number]["device"][
- "gui"
- ]["label"]
- area = self.coordinator.data["climate"][self.serial_number]["device"]["area"]
- return DeviceInfo(
- name=area,
+ device = coordinator.data["climate"][serial_number]["device"]
+ device_type = device["gui"]["label"]
+ self._attr_device_info = DeviceInfo(
+ name=device["area"],
manufacturer="Verisure",
model=DEVICE_TYPE_NAME.get(device_type, device_type),
- identifiers={(DOMAIN, self.serial_number)},
- via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]),
+ identifiers={(DOMAIN, serial_number)},
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.data[CONF_GIID]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
configuration_url="https://mypages.verisure.com",
)
diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py
index e7727fa52dc6..b855a784f5cd 100644
--- a/homeassistant/components/verisure/switch.py
+++ b/homeassistant/components/verisure/switch.py
@@ -5,6 +5,7 @@ from typing import Any, override
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -42,20 +43,17 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch
self.serial_number = serial_number
self._change_timestamp: float = 0
self._state = False
-
- @property
- @override
- def device_info(self) -> DeviceInfo:
- """Return device information about this entity."""
- area = self.coordinator.data["smart_plugs"][self.serial_number]["device"][
- "area"
- ]
- return DeviceInfo(
+ area = coordinator.data["smart_plugs"][serial_number]["device"]["area"]
+ self._attr_device_info = DeviceInfo(
name=area,
manufacturer="Verisure",
model="SmartPlug",
- identifiers={(DOMAIN, self.serial_number)},
- via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]),
+ identifiers={(DOMAIN, serial_number)},
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.data[CONF_GIID]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
configuration_url="https://mypages.verisure.com",
)
diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py
index 28982b63ec05..c6f8d272c41f 100644
--- a/homeassistant/components/watts/coordinator.py
+++ b/homeassistant/components/watts/coordinator.py
@@ -152,10 +152,7 @@ class WattsVisionHubCoordinator(DataUpdateCoordinator[dict[str, Device]]):
(DOMAIN, device_id), self.config_entry.entry_id
)
if device:
- 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)
@property
def device_ids(self) -> list[str]:
diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py
index de80c3bc1a53..bbbc54b2a0dc 100644
--- a/homeassistant/components/weatherkit/coordinator.py
+++ b/homeassistant/components/weatherkit/coordinator.py
@@ -1,6 +1,7 @@
"""DataUpdateCoordinator for WeatherKit integration."""
-from datetime import datetime, timedelta
+from datetime import timedelta
+import time
from typing import override
from apple_weatherkit import DataSetType
@@ -20,7 +21,7 @@ REQUESTED_DATA_SETS = [
DataSetType.HOURLY_FORECAST,
]
-STALE_DATA_THRESHOLD = timedelta(hours=1)
+STALE_DATA_THRESHOLD = timedelta(hours=1).total_seconds()
HOURLY_FORECAST_DURATION = timedelta(days=7)
@@ -32,7 +33,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator):
config_entry: WeatherKitConfigEntry
supported_data_sets: list[DataSetType] | None = None
- last_updated_at: datetime | None = None
+ last_updated_at: float | None = None
def __init__(
self,
@@ -83,12 +84,12 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator):
except WeatherKitApiClientError as exception:
if self.data is None or (
self.last_updated_at is not None
- and datetime.now() - self.last_updated_at > STALE_DATA_THRESHOLD # pylint: disable=home-assistant-enforce-naive-now
+ and time.time() - self.last_updated_at > STALE_DATA_THRESHOLD
):
raise UpdateFailed(exception) from exception
LOGGER.debug("Using stale data because update failed: %s", exception)
return self.data
else:
- self.last_updated_at = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
+ self.last_updated_at = time.time()
return updated_data
diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json
index 241483ad4a54..56be93a8e443 100644
--- a/homeassistant/components/webostv/manifest.json
+++ b/homeassistant/components/webostv/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiowebostv"],
"quality_scale": "platinum",
- "requirements": ["aiowebostv==0.8.0"],
+ "requirements": ["aiowebostv==0.9.0"],
"ssdp": [
{
"st": "urn:lge-com:service:webos-second-screen:1"
diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json
index 83d724c308a4..140ac44547d2 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.5"],
+ "requirements": ["wiim==0.1.6"],
"zeroconf": ["_linkplay._tcp.local."]
}
diff --git a/homeassistant/components/wilight/entity.py b/homeassistant/components/wilight/entity.py
index bb360ab2805d..562b9c47a460 100644
--- a/homeassistant/components/wilight/entity.py
+++ b/homeassistant/components/wilight/entity.py
@@ -35,7 +35,6 @@ class WiLightDevice(Entity):
model=api_device.model,
manufacturer="WiLight",
sw_version=api_device.swversion,
- via_device=(DOMAIN, self._device_id),
)
@property
diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py
index bc7ce5c21f35..79b2e2b0e359 100644
--- a/homeassistant/components/withings/entity.py
+++ b/homeassistant/components/withings/entity.py
@@ -4,6 +4,7 @@ from typing import Any, override
from aiowithings import Device
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -50,7 +51,11 @@ class WithingsDeviceEntity(WithingsEntity[WithingsDeviceDataUpdateCoordinator]):
manufacturer="Withings",
name=self.device.raw_model,
model=self.device.raw_model,
- via_device=(DOMAIN, str(coordinator.config_entry.unique_id)),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, str(coordinator.config_entry.unique_id)),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
@property
diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py
index 73b56a8dae35..2967869322e6 100644
--- a/homeassistant/components/withings/sensor.py
+++ b/homeassistant/components/withings/sensor.py
@@ -875,16 +875,10 @@ async def async_setup_entry(
if old_devices:
device_registry = dr.async_get(hass)
for device_id in old_devices:
- # 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 := device_registry.async_get_device_by_identifier(
+ (DOMAIN, device_id), entry.entry_id
):
- if device.config_entry_id == entry.entry_id:
- device_registry.async_update_device(
- device.id, remove_config_entry_id=entry.entry_id
- )
- break
+ device_registry.async_remove_device(device.id)
current_devices.remove(device_id)
device_coordinator.async_add_listener(_async_device_listener)
diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py
index 450f58571095..aa953fb534a1 100644
--- a/homeassistant/components/wmspro/button.py
+++ b/homeassistant/components/wmspro/button.py
@@ -24,10 +24,12 @@ async def async_setup_entry(
entities: list[WebControlProGenericEntity] = []
for dest in hub.dests.values():
if dest.hasAction(ACTION_DESC.Identify):
- entities.append(WebControlProIdentifyButton(config_entry.entry_id, dest))
+ entities.append(
+ WebControlProIdentifyButton(hass, config_entry.entry_id, dest)
+ )
if dest.hasAction(ACTION_DESC.SlatRotate):
entities.append(
- WebControlProRotationResetButton(config_entry.entry_id, dest)
+ WebControlProRotationResetButton(hass, config_entry.entry_id, dest)
)
async_add_entities(entities)
diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py
index 15c4acb4fd31..4777a2a7892e 100644
--- a/homeassistant/components/wmspro/cover.py
+++ b/homeassistant/components/wmspro/cover.py
@@ -40,16 +40,20 @@ async def async_setup_entry(
entities: list[WebControlProGenericEntity] = []
for dest in hub.dests.values():
if dest.hasAction(ACTION_DESC.AwningDrive):
- entities.append(WebControlProAwning(config_entry.entry_id, dest))
+ entities.append(WebControlProAwning(hass, config_entry.entry_id, dest))
if dest.hasAction(ACTION_DESC.ValanceDrive):
- entities.append(WebControlProValance(config_entry.entry_id, dest))
+ entities.append(WebControlProValance(hass, config_entry.entry_id, dest))
elif dest.hasAction(ACTION_DESC.RollerShutterBlindDrive):
- entities.append(WebControlProRollerShutter(config_entry.entry_id, dest))
+ entities.append(
+ WebControlProRollerShutter(hass, config_entry.entry_id, dest)
+ )
elif dest.hasAction(ACTION_DESC.SlatDrive):
if dest.hasAction(ACTION_DESC.SlatRotate):
- entities.append(WebControlProSlatRotate(config_entry.entry_id, dest))
+ entities.append(
+ WebControlProSlatRotate(hass, config_entry.entry_id, dest)
+ )
else:
- entities.append(WebControlProSlat(config_entry.entry_id, dest))
+ entities.append(WebControlProSlat(hass, config_entry.entry_id, dest))
async_add_entities(entities)
diff --git a/homeassistant/components/wmspro/entity.py b/homeassistant/components/wmspro/entity.py
index 81346ce1e491..4ac92009db2e 100644
--- a/homeassistant/components/wmspro/entity.py
+++ b/homeassistant/components/wmspro/entity.py
@@ -4,6 +4,8 @@ from typing import override
from wmspro.destination import Destination
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -16,7 +18,9 @@ class WebControlProGenericEntity(Entity):
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
- def __init__(self, config_entry_id: str, dest: Destination) -> None:
+ def __init__(
+ self, hass: HomeAssistant, config_entry_id: str, dest: Destination
+ ) -> None:
"""Initialize the entity with destination channel."""
dest_id_str = str(dest.id)
self._dest = dest
@@ -30,7 +34,9 @@ class WebControlProGenericEntity(Entity):
name=dest.name,
serial_number=dest_id_str,
suggested_area=dest.room.name,
- via_device=(DOMAIN, config_entry_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, config_entry_id), config_entry_id=config_entry_id
+ ),
configuration_url=f"http://{dest.host}/control",
)
diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py
index a58e784f56f8..698d9aa19b60 100644
--- a/homeassistant/components/wmspro/light.py
+++ b/homeassistant/components/wmspro/light.py
@@ -32,9 +32,9 @@ async def async_setup_entry(
entities: list[WebControlProGenericEntity] = []
for dest in hub.dests.values():
if dest.hasAction(ACTION_DESC.LightDimming):
- entities.append(WebControlProDimmer(config_entry.entry_id, dest))
+ entities.append(WebControlProDimmer(hass, config_entry.entry_id, dest))
elif dest.hasAction(ACTION_DESC.LightSwitch):
- entities.append(WebControlProLight(config_entry.entry_id, dest))
+ entities.append(WebControlProLight(hass, config_entry.entry_id, dest))
async_add_entities(entities)
diff --git a/homeassistant/components/wmspro/number.py b/homeassistant/components/wmspro/number.py
index b6b25cbfb22e..3b5df5c8c55a 100644
--- a/homeassistant/components/wmspro/number.py
+++ b/homeassistant/components/wmspro/number.py
@@ -29,12 +29,20 @@ async def async_setup_entry(
entities: list[WebControlProGenericEntity] = []
for dest in hub.dests.values():
if dest.hasAction(ACTION_DESC.SlatRotate):
- entities.append(WebControlProSlatRangeMin(config_entry.entry_id, dest))
- entities.append(WebControlProSlatRangeMax(config_entry.entry_id, dest))
- entities.append(WebControlProSlatRotationRaw(config_entry.entry_id, dest))
+ entities.append(
+ WebControlProSlatRangeMin(hass, config_entry.entry_id, dest)
+ )
+ entities.append(
+ WebControlProSlatRangeMax(hass, config_entry.entry_id, dest)
+ )
+ entities.append(
+ WebControlProSlatRotationRaw(hass, config_entry.entry_id, dest)
+ )
if not dest.hasAction(ACTION_DESC.SlatDrive):
# Only add the numeric slat rotation entity if no cover entity exists
- entities.append(WebControlProSlatRotation(config_entry.entry_id, dest))
+ entities.append(
+ WebControlProSlatRotation(hass, config_entry.entry_id, dest)
+ )
async_add_entities(entities)
diff --git a/homeassistant/components/wmspro/scene.py b/homeassistant/components/wmspro/scene.py
index ec6ac192e6d5..2f7917d037d8 100644
--- a/homeassistant/components/wmspro/scene.py
+++ b/homeassistant/components/wmspro/scene.py
@@ -6,6 +6,7 @@ from wmspro.scene import Scene as WMS_Scene
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -22,7 +23,7 @@ async def async_setup_entry(
hub = config_entry.runtime_data
async_add_entities(
- WebControlProScene(config_entry.entry_id, scene)
+ WebControlProScene(hass, config_entry.entry_id, scene)
for scene in hub.scenes.values()
)
@@ -33,7 +34,9 @@ class WebControlProScene(Scene):
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
- def __init__(self, config_entry_id: str, scene: WMS_Scene) -> None:
+ def __init__(
+ self, hass: HomeAssistant, config_entry_id: str, scene: WMS_Scene
+ ) -> None:
"""Initialize the entity with the configured scene."""
super().__init__()
@@ -53,7 +56,9 @@ class WebControlProScene(Scene):
name=room_name,
serial_number=room_id_str,
suggested_area=room_name,
- via_device=(DOMAIN, config_entry_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, config_entry_id), config_entry_id=config_entry_id
+ ),
configuration_url=f"http://{scene.host}/control",
)
diff --git a/homeassistant/components/wmspro/switch.py b/homeassistant/components/wmspro/switch.py
index f681c56a5b47..98b52d95bee8 100644
--- a/homeassistant/components/wmspro/switch.py
+++ b/homeassistant/components/wmspro/switch.py
@@ -28,7 +28,7 @@ async def async_setup_entry(
hub = config_entry.runtime_data
async_add_entities(
- WebControlProSwitch(config_entry.entry_id, dest)
+ WebControlProSwitch(hass, config_entry.entry_id, dest)
for dest in hub.dests.values()
if dest.hasAction(ACTION_DESC.LoadSwitch)
)
diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py
index 69e14fa6b4f5..4c9be755dd18 100644
--- a/homeassistant/components/xiaomi_aqara/entity.py
+++ b/homeassistant/components/xiaomi_aqara/entity.py
@@ -103,6 +103,7 @@ class XiaomiDevice(Entity):
else:
if TYPE_CHECKING:
assert self._gateway_id is not None
+ assert self.platform.config_entry is not None
device_info = DeviceInfo(
connections={(dr.CONNECTION_ZIGBEE, self._device_id)},
identifiers={(DOMAIN, self._device_id)},
@@ -110,7 +111,11 @@ class XiaomiDevice(Entity):
model=self._model,
name=self._device_name,
sw_version=self._protocol,
- via_device=(DOMAIN, self._gateway_id),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ self.hass,
+ (DOMAIN, self._gateway_id),
+ config_entry_id=self.platform.config_entry.entry_id,
+ ),
)
return device_info
diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py
index ec410077fe17..d586bf027c75 100644
--- a/homeassistant/components/yale_smart_alarm/__init__.py
+++ b/homeassistant/components/yale_smart_alarm/__init__.py
@@ -4,7 +4,7 @@ from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMA
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CODE, CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import LOGGER, PLATFORMS
from .coordinator import YaleDataUpdateCoordinator
@@ -19,6 +19,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
+ # Register the alarm panel device so child devices can link to it via
+ # via_device_id, since the panel is otherwise only registered by a sibling
+ # entity on a concurrently loaded platform.
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **coordinator.device_info,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py
index f8cd3d726eb1..085cd2298a5c 100644
--- a/homeassistant/components/yale_smart_alarm/coordinator.py
+++ b/homeassistant/components/yale_smart_alarm/coordinator.py
@@ -10,12 +10,20 @@ from yalesmartalarmclient.exceptions import AuthenticationError
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
if TYPE_CHECKING:
from . import YaleConfigEntry
-from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, YALE_BASE_ERRORS
+from .const import (
+ DEFAULT_SCAN_INTERVAL,
+ DOMAIN,
+ LOGGER,
+ MANUFACTURER,
+ MODEL,
+ YALE_BASE_ERRORS,
+)
class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -36,6 +44,19 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
self.locks: list[YaleLock] = []
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return device info for the alarm panel."""
+ panel_info = self.data["panel_info"]
+ return DeviceInfo(
+ identifiers={(DOMAIN, self.config_entry.data[CONF_USERNAME])},
+ connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])},
+ manufacturer=MANUFACTURER,
+ model=MODEL,
+ name=self.config_entry.title,
+ sw_version=panel_info["version"],
+ )
+
def _yale_setup(self) -> tuple[YaleSmartAlarmClient, list[YaleLock]]:
"""Set up connection to Yale."""
yale = YaleSmartAlarmClient(
diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py
index 2610f54f0a90..5bbe78c5a4fb 100644
--- a/homeassistant/components/yale_smart_alarm/entity.py
+++ b/homeassistant/components/yale_smart_alarm/entity.py
@@ -3,7 +3,8 @@
from yalesmartalarmclient import YaleLock
from homeassistant.const import CONF_USERNAME
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -25,7 +26,11 @@ class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator]):
manufacturer=MANUFACTURER,
model=MODEL,
identifiers={(DOMAIN, data["address"])},
- via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.data[CONF_USERNAME]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
@@ -43,7 +48,11 @@ class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]):
manufacturer=MANUFACTURER,
model=MODEL,
identifiers={(DOMAIN, lock.sid())},
- via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.config_entry.data[CONF_USERNAME]),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
self.lock_data = lock
@@ -56,12 +65,4 @@ class YaleAlarmEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity):
def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None:
"""Initialize an Yale device."""
super().__init__(coordinator)
- panel_info = coordinator.data["panel_info"]
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, coordinator.config_entry.data[CONF_USERNAME])},
- manufacturer=MANUFACTURER,
- model=MODEL,
- name=coordinator.config_entry.title,
- connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])},
- sw_version=panel_info["version"],
- )
+ self._attr_device_info = coordinator.device_info
diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py
index b6b7ffcbef0d..3eb0db27b6fb 100644
--- a/homeassistant/components/yardian/__init__.py
+++ b/homeassistant/components/yardian/__init__.py
@@ -4,6 +4,7 @@ from pyyardian import AsyncYardianClient
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import YardianConfigEntry, YardianUpdateCoordinator
@@ -33,6 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: YardianConfigEntry) -> b
entry.runtime_data = coordinator
+ # Register the main device so zone child devices can resolve it as their
+ # via_device regardless of platform setup order.
+ dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **coordinator.device_info,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/yardian/entity.py b/homeassistant/components/yardian/entity.py
index a590d1cea714..e905937b6c5f 100644
--- a/homeassistant/components/yardian/entity.py
+++ b/homeassistant/components/yardian/entity.py
@@ -1,5 +1,6 @@
"""Base entities for Yardian integration."""
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -31,5 +32,9 @@ class YardianZoneEntity(CoordinatorEntity[YardianUpdateCoordinator]):
identifiers={(DOMAIN, f"{coordinator.yid}_{zone_id}")},
name=coordinator.data.zones[zone_id].name,
manufacturer=MANUFACTURER,
- via_device=(DOMAIN, coordinator.yid),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ coordinator.hass,
+ (DOMAIN, coordinator.yid),
+ config_entry_id=coordinator.config_entry.entry_id,
+ ),
)
diff --git a/homeassistant/components/zimi/cover.py b/homeassistant/components/zimi/cover.py
index c3deb122dea3..8374f96673b6 100644
--- a/homeassistant/components/zimi/cover.py
+++ b/homeassistant/components/zimi/cover.py
@@ -26,9 +26,13 @@ async def async_setup_entry(
api = config_entry.runtime_data
- covers = [ZimiCover(device, api) for device in api.blinds]
+ covers = [
+ ZimiCover(hass, device, api, config_entry.entry_id) for device in api.blinds
+ ]
- covers.extend(ZimiCover(device, api) for device in api.doors)
+ covers.extend(
+ ZimiCover(hass, device, api, config_entry.entry_id) for device in api.doors
+ )
async_add_entities(covers)
diff --git a/homeassistant/components/zimi/entity.py b/homeassistant/components/zimi/entity.py
index eb631b114a32..139abc526305 100644
--- a/homeassistant/components/zimi/entity.py
+++ b/homeassistant/components/zimi/entity.py
@@ -6,6 +6,8 @@ from typing import override
from zcc import ControlPoint
from zcc.device import ControlPointDevice
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -21,7 +23,12 @@ class ZimiEntity(Entity):
_attr_has_entity_name = True
def __init__(
- self, device: ControlPointDevice, api: ControlPoint, use_device_name=True
+ self,
+ hass: HomeAssistant,
+ device: ControlPointDevice,
+ api: ControlPoint,
+ config_entry_id: str,
+ use_device_name: bool = True,
) -> None:
"""Initialize an HA Entity which is a ZimiDevice."""
@@ -35,7 +42,9 @@ class ZimiEntity(Entity):
hw_version=device.manufacture_info.hwVersion,
sw_version=device.manufacture_info.firmwareVersion,
suggested_area=device.room,
- via_device=(DOMAIN, api.mac),
+ via_device_id=dr.async_get_device_id_by_identifier(
+ hass, (DOMAIN, api.mac), config_entry_id=config_entry_id
+ ),
)
if use_device_name:
self._attr_name = device.name.strip()
diff --git a/homeassistant/components/zimi/fan.py b/homeassistant/components/zimi/fan.py
index 919cb764e8da..2d46796b5f34 100644
--- a/homeassistant/components/zimi/fan.py
+++ b/homeassistant/components/zimi/fan.py
@@ -28,7 +28,9 @@ async def async_setup_entry(
api = config_entry.runtime_data
- async_add_entities([ZimiFan(device, api) for device in api.fans])
+ async_add_entities(
+ ZimiFan(hass, device, api, config_entry.entry_id) for device in api.fans
+ )
class ZimiFan(ZimiEntity, FanEntity):
diff --git a/homeassistant/components/zimi/light.py b/homeassistant/components/zimi/light.py
index c2abcd0daf67..b26f93258ada 100644
--- a/homeassistant/components/zimi/light.py
+++ b/homeassistant/components/zimi/light.py
@@ -26,11 +26,15 @@ async def async_setup_entry(
api = config_entry.runtime_data
lights: list[ZimiLight | ZimiDimmer] = [
- ZimiLight(device, api) for device in api.lights if device.type != "dimmer"
+ ZimiLight(hass, device, api, config_entry.entry_id)
+ for device in api.lights
+ if device.type != "dimmer"
]
lights.extend(
- ZimiDimmer(device, api) for device in api.lights if device.type == "dimmer"
+ ZimiDimmer(hass, device, api, config_entry.entry_id)
+ for device in api.lights
+ if device.type == "dimmer"
)
async_add_entities(lights)
@@ -39,10 +43,16 @@ async def async_setup_entry(
class ZimiLight(ZimiEntity, LightEntity):
"""Representation of a Zimi Light."""
- def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ device: ControlPointDevice,
+ api: ControlPoint,
+ config_entry_id: str,
+ ) -> None:
"""Initialize a ZimiLight."""
- super().__init__(device, api)
+ super().__init__(hass, device, api, config_entry_id)
self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = {ColorMode.ONOFF}
@@ -77,9 +87,15 @@ class ZimiLight(ZimiEntity, LightEntity):
class ZimiDimmer(ZimiLight):
"""Zimi Light supporting dimming."""
- def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ device: ControlPointDevice,
+ api: ControlPoint,
+ config_entry_id: str,
+ ) -> None:
"""Initialize a ZimiDimmer."""
- super().__init__(device, api)
+ super().__init__(hass, device, api, config_entry_id)
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
diff --git a/homeassistant/components/zimi/sensor.py b/homeassistant/components/zimi/sensor.py
index 2ab507343a14..306043b2670e 100644
--- a/homeassistant/components/zimi/sensor.py
+++ b/homeassistant/components/zimi/sensor.py
@@ -71,7 +71,7 @@ async def async_setup_entry(
api = config_entry.runtime_data
async_add_entities(
- ZimiSensor(device, description, api)
+ ZimiSensor(hass, device, description, api, config_entry.entry_id)
for device in api.sensors
for description in GARAGE_SENSOR_DESCRIPTIONS
)
@@ -84,13 +84,15 @@ class ZimiSensor(ZimiEntity, SensorEntity):
def __init__(
self,
+ hass: HomeAssistant,
device: ControlPointDevice,
description: ZimiSensorEntityDescription,
api: ControlPoint,
+ config_entry_id: str,
) -> None:
"""Initialize an ZimiSensor with specified type."""
- super().__init__(device, api, use_device_name=False)
+ super().__init__(hass, device, api, config_entry_id, use_device_name=False)
self.entity_description = description
self._attr_unique_id = device.identifier + "." + self.entity_description.key
diff --git a/homeassistant/components/zimi/switch.py b/homeassistant/components/zimi/switch.py
index 689e348acd8a..acb3b89049ea 100644
--- a/homeassistant/components/zimi/switch.py
+++ b/homeassistant/components/zimi/switch.py
@@ -22,7 +22,9 @@ async def async_setup_entry(
api = config_entry.runtime_data
- outlets = [ZimiSwitch(device, api) for device in api.outlets]
+ outlets = [
+ ZimiSwitch(hass, device, api, config_entry.entry_id) for device in api.outlets
+ ]
async_add_entities(outlets)
diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py
index 340caab8481c..bd6930be8c21 100644
--- a/homeassistant/components/zwave_js/__init__.py
+++ b/homeassistant/components/zwave_js/__init__.py
@@ -676,10 +676,7 @@ class ControllerEvents:
):
# If a device entry is registered with the node ID based identifiers,
# just remove the device entry with the DSK identifier.
- self.dev_reg.async_update_device(
- pre_provisioned_device.id,
- remove_config_entry_id=self.config_entry.entry_id,
- )
+ self.dev_reg.async_remove_device(pre_provisioned_device.id)
else:
# Add the node ID based identifiers to the device entry
# with the DSK identifier and remove the DSK identifier.
diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py
index db6fde0f7871..564801960484 100644
--- a/homeassistant/components/zwave_js/config_flow.py
+++ b/homeassistant/components/zwave_js/config_flow.py
@@ -3,7 +3,6 @@
import asyncio
import base64
from contextlib import suppress
-from datetime import datetime
import logging
from pathlib import Path
from typing import Any, override
@@ -40,6 +39,7 @@ from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
+from homeassistant.util import dt as dt_util
from .addon import get_addon_manager
from .const import (
@@ -1629,7 +1629,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
# save the backup to a file just in case
self.backup_filepath = Path(
self.hass.config.path(
- f"zwavejs_nvm_backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin" # pylint: disable=home-assistant-enforce-naive-now
+ f"zwavejs_nvm_backup_{dt_util.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin"
)
)
try:
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 86a5110b6eb4..20af0f536240 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -37,7 +37,7 @@ go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.26.5
hass-nabucasa==2.2.0
-hassil==3.10.0
+hassil==3.11.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260729.3
home-assistant-intents==2026.7.30
diff --git a/requirements.txt b/requirements.txt
index 5c0c6fd46d07..b291a8420f24 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -25,7 +25,7 @@ cryptography==48.0.1
fnv-hash-fast==2.0.3
ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
-hassil==3.10.0
+hassil==3.11.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.7.30
httpx==0.28.1
diff --git a/requirements_all.txt b/requirements_all.txt
index b61f572ace20..90b70f8be763 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -471,7 +471,7 @@ aiowatttime==0.1.1
aiowebdav2==0.6.2
# homeassistant.components.webostv
-aiowebostv==0.8.0
+aiowebostv==0.9.0
# homeassistant.components.withings
aiowithings==3.1.6
@@ -525,7 +525,7 @@ androidtvremote2==0.3.1
anel-pwrctrl-homeassistant==0.0.1.dev2
# homeassistant.components.anova
-anova-wifi==0.17.1
+anova-wifi==1.0.1
# homeassistant.components.anthemav
anthemav==1.4.2
@@ -1137,7 +1137,7 @@ google-cloud-speech==2.31.1
google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
-google-genai==1.59.0
+google-genai==2.16.0
# homeassistant.components.google_health
google-health-api==0.6.0
@@ -1238,7 +1238,7 @@ hass-splunk==0.1.4
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
-hassil==3.10.0
+hassil==3.11.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.2.1
@@ -1435,7 +1435,7 @@ kiwiki-client==0.1.2
knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2026.7.23.145751
+knx-frontend==2026.8.2.133643
# homeassistant.components.knx
knx-telegram-store[sqlite,postgres]==0.11.2
@@ -2267,7 +2267,7 @@ pyimouapi==1.3.0
pyinsteon==1.6.4
# homeassistant.components.intelliclima
-pyintelliclima==0.3.1
+pyintelliclima==0.4.0
# homeassistant.components.intesishome
pyintesishome==2.0.3
@@ -3065,7 +3065,7 @@ soco==0.31.2
solaredge-local==0.2.3
# homeassistant.components.solaredge
-solaredge-web==0.0.1
+solaredge-web==0.3.0
# homeassistant.components.solarlog
solarlog_cli==0.7.1
@@ -3241,7 +3241,7 @@ ttn_client==1.3.0
tuya-device-handlers==0.0.26
# homeassistant.components.tuya
-tuya-device-sharing-sdk==0.2.10
+tuya-device-sharing-sdk==0.2.14
# homeassistant.components.twentemilieu
twentemilieu==3.0.0
@@ -3385,7 +3385,7 @@ whois==0.9.27
wiffi==1.1.2
# homeassistant.components.wiim
-wiim==0.1.5
+wiim==0.1.6
# homeassistant.components.wirelesstag
wirelesstagpy==0.8.1
diff --git a/script/licenses.py b/script/licenses.py
index 0d4dac8d84b7..ae9279728336 100644
--- a/script/licenses.py
+++ b/script/licenses.py
@@ -90,6 +90,7 @@ OSI_APPROVED_LICENSES_SPDX = {
"PSF-2.0",
"Python-2.0",
"Unlicense",
+ "UPL-1.0",
"Zlib",
"ZPL-2.1",
}
diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py
index f75330af1b5c..dbc36fa295ff 100644
--- a/tests/auth/test_init.py
+++ b/tests/auth/test_init.py
@@ -285,9 +285,7 @@ async def test_linking_user_to_two_auth_providers(
user = await manager.async_get_or_create_user(credential)
assert user is not None
- step = await manager.login_flow.async_init(
- ("insecure_example", "another-provider"), context={"credential_only": True}
- )
+ step = await manager.login_flow.async_init(("insecure_example", "another-provider"))
step = await manager.login_flow.async_configure(
step["flow_id"], {"username": "another-user", "password": "another-password"}
)
diff --git a/tests/components/anthemav/test_init.py b/tests/components/anthemav/test_init.py
index 883b41bbe5a9..e9d4ce371ee8 100644
--- a/tests/components/anthemav/test_init.py
+++ b/tests/components/anthemav/test_init.py
@@ -47,8 +47,15 @@ async def test_device_registry(
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "00:00:00:00:00:01")}
)
+ assert device_entry
assert device_entry == snapshot
+ zone_2_device_entry = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "00:00:00:00:00:01_2"), init_integration.entry_id
+ )
+ assert zone_2_device_entry
+ assert zone_2_device_entry.via_device_id == device_entry.id
+
@pytest.mark.parametrize("error", [OSError, DeviceError])
async def test_config_entry_not_ready_when_oserror(
diff --git a/tests/components/arcam_fmj/test_init.py b/tests/components/arcam_fmj/test_init.py
new file mode 100644
index 000000000000..2c7d31087ddf
--- /dev/null
+++ b/tests/components/arcam_fmj/test_init.py
@@ -0,0 +1,30 @@
+"""Tests for the Arcam FMJ config entry setup."""
+
+import pytest
+
+from homeassistant.components.arcam_fmj.const import DOMAIN
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from .conftest import MOCK_UUID
+
+from tests.common import MockConfigEntry
+
+
+@pytest.mark.usefixtures("player_setup")
+async def test_device_via_device_links(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that the zone 2 device links to the zone 1 device via via_device_id."""
+ zone1_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, MOCK_UUID), mock_config_entry.entry_id
+ )
+ assert zone1_device is not None
+
+ zone2_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{MOCK_UUID}-2"), mock_config_entry.entry_id
+ )
+ assert zone2_device is not None
+ assert zone2_device.via_device_id == zone1_device.id
diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py
index c8ad285f02df..55724d55cd90 100644
--- a/tests/components/bluesound/conftest.py
+++ b/tests/components/bluesound/conftest.py
@@ -159,6 +159,23 @@ def config_entry_secondary() -> MockConfigEntry:
)
+@pytest.fixture
+def config_entry_non_default_port() -> MockConfigEntry:
+ """Return a mocked config entry for a non-default-port player.
+
+ Shares its mac (via player_mocks) with the leader `config_entry` at the
+ default port.
+ """
+ return MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_HOST: "1.1.1.2",
+ CONF_PORT: 11001,
+ },
+ unique_id="ff:ff:01:01:01:01-11001",
+ )
+
+
@pytest.fixture
async def setup_config_entry(
hass: HomeAssistant, config_entry: MockConfigEntry, player_mocks: PlayerMocks
diff --git a/tests/components/bluesound/test_init.py b/tests/components/bluesound/test_init.py
index b373ca743cd8..436a655308eb 100644
--- a/tests/components/bluesound/test_init.py
+++ b/tests/components/bluesound/test_init.py
@@ -2,8 +2,10 @@
from pyblu.errors import PlayerUnreachableError
+from homeassistant.components.bluesound import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from .conftest import PlayerMocks
@@ -45,3 +47,50 @@ async def test_unload_entry_while_player_is_offline(
assert hass.states.get("media_player.player_name1111").state == "unavailable"
assert config_entry.state is ConfigEntryState.NOT_LOADED
+
+
+async def test_non_default_port_links_to_leader_via_device_id(
+ hass: HomeAssistant,
+ setup_config_entry: None,
+ config_entry_non_default_port: MockConfigEntry,
+ player_mocks: PlayerMocks,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test a non-default-port player links to the already-registered leader device."""
+ config_entry_non_default_port.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry_non_default_port.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry_non_default_port.state is ConfigEntryState.LOADED
+
+ leader_devices = device_registry.async_get_devices(
+ identifiers={(DOMAIN, "ff:ff:01:01:01:01")}
+ )
+ assert leader_devices
+ leader_device = leader_devices[0]
+
+ child_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "ff:ff:01:01:01:01-11001"), config_entry_non_default_port.entry_id
+ )
+ assert child_device is not None
+ assert child_device.via_device_id == leader_device.id
+
+
+async def test_non_default_port_without_leader_has_no_via_device_id(
+ hass: HomeAssistant,
+ config_entry_non_default_port: MockConfigEntry,
+ player_mocks: PlayerMocks,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test a non-default-port player sets up fine when the leader device is absent."""
+ config_entry_non_default_port.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry_non_default_port.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry_non_default_port.state is ConfigEntryState.LOADED
+
+ child_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "ff:ff:01:01:01:01-11001"), config_entry_non_default_port.entry_id
+ )
+ assert child_device is not None
+ assert child_device.via_device_id is None
diff --git a/tests/components/bosch_alarm/test_binary_sensor.py b/tests/components/bosch_alarm/test_binary_sensor.py
index e788d7c5eda5..d0e3970d1c1f 100644
--- a/tests/components/bosch_alarm/test_binary_sensor.py
+++ b/tests/components/bosch_alarm/test_binary_sensor.py
@@ -7,9 +7,10 @@ from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS
import pytest
from syrupy.assertion import SnapshotAssertion
+from homeassistant.components.bosch_alarm.const import DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import call_observable, setup_integration
@@ -76,3 +77,30 @@ async def test_area_ready_to_arm(
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id_2).state == STATE_OFF
+
+
+@pytest.mark.parametrize("model", ["b5512"])
+@pytest.mark.usefixtures("mock_panel")
+async def test_point_via_device_id(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that a point's device links to the panel device via via_device_id."""
+ await setup_integration(hass, mock_config_entry)
+
+ entity_entry = entity_registry.async_get("binary_sensor.window")
+ assert entity_entry
+ assert entity_entry.device_id
+
+ point_device = device_registry.async_get(entity_entry.device_id)
+ assert point_device
+
+ panel_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, mock_config_entry.unique_id),
+ mock_config_entry.entry_id,
+ )
+ assert panel_device
+
+ assert point_device.via_device_id == panel_device.id
diff --git a/tests/components/bosch_shc/test_entity.py b/tests/components/bosch_shc/test_entity.py
new file mode 100644
index 000000000000..63904273ae28
--- /dev/null
+++ b/tests/components/bosch_shc/test_entity.py
@@ -0,0 +1,78 @@
+"""Tests for the Bosch SHC entity base classes."""
+
+from unittest.mock import MagicMock
+
+from boschshcpy import SHCDevice
+
+from homeassistant.components.bosch_shc.const import DOMAIN
+from homeassistant.components.bosch_shc.entity import SHCEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from tests.common import MockConfigEntry
+
+
+async def test_shc_entity_via_device_id(
+ hass: HomeAssistant, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test SHCEntity links its device to the SHC hub via via_device_id."""
+ entry = MockConfigEntry(domain=DOMAIN)
+ entry.add_to_hass(hass)
+
+ hub_device = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "root-serial")},
+ manufacturer="Bosch",
+ name="Bosch SHC",
+ model="SmartHomeController",
+ )
+
+ device = MagicMock(spec=SHCDevice)
+ device.serial = "child-serial"
+ device.manufacturer = "Bosch"
+ device.device_model = "SWD"
+ device.name = "Shutter Contact"
+ device.id = "child-id"
+ device.root_device_id = "root-serial"
+
+ entity = SHCEntity(
+ hass=hass, device=device, parent_id="root-serial", entry_id=entry.entry_id
+ )
+
+ assert entity.device_info is not None
+ assert entity.device_info["via_device_id"] == hub_device.id
+
+
+async def test_shc_entity_via_device_id_mismatch(
+ hass: HomeAssistant, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test SHCEntity sets up without a link when the hub identifier does not match.
+
+ boschshcpy may render the hub identifier and a device's root_device_id
+ differently, so the lookup can miss; setup must not crash in that case.
+ """
+ entry = MockConfigEntry(domain=DOMAIN)
+ entry.add_to_hass(hass)
+
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "hub-serial")},
+ manufacturer="Bosch",
+ name="Bosch SHC",
+ model="SmartHomeController",
+ )
+
+ device = MagicMock(spec=SHCDevice)
+ device.serial = "child-serial"
+ device.manufacturer = "Bosch"
+ device.device_model = "SWD"
+ device.name = "Shutter Contact"
+ device.id = "child-id"
+ device.root_device_id = "root-serial-mismatch"
+
+ entity = SHCEntity(
+ hass=hass, device=device, parent_id="hub-serial", entry_id=entry.entry_id
+ )
+
+ assert entity.device_info is not None
+ assert "via_device_id" not in entity.device_info
diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py
index c2ffa6bcfe4a..b4edf4d1c30d 100644
--- a/tests/components/device_tracker/test_entity.py
+++ b/tests/components/device_tracker/test_entity.py
@@ -24,7 +24,12 @@ from homeassistant.components.device_tracker import (
TrackingType,
)
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigEntryState,
+ ConfigFlow,
+ ConfigSubentryData,
+)
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_FRIENDLY_NAME,
@@ -117,6 +122,7 @@ async def create_mock_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entities: list[Entity],
+ config_subentry_id: str | None = None,
) -> MockConfigEntry:
"""Create a device tracker platform with the specified entities."""
@@ -126,7 +132,7 @@ async def create_mock_platform(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up test event platform via config entry."""
- async_add_entities(entities)
+ async_add_entities(entities, config_subentry_id=config_subentry_id)
mock_platform(
hass,
@@ -1292,7 +1298,7 @@ async def test_scanner_entity_state(
ATTR_IP: ip_address,
ATTR_MAC: mac_address,
ATTR_HOST_NAME: hostname,
- ATTR_FRIENDLY_NAME: "Device from other integration",
+ ATTR_FRIENDLY_NAME: hostname,
}
assert entity_state.state == STATE_NOT_HOME
@@ -1664,10 +1670,10 @@ async def test_scanner_entity_composite_device_without_own_split(
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
- """A composite with no split owned by the scanner's config entry attaches nothing.
+ """A composite with no split owned by the scanner's config entry.
- 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.
+ The tracked device is known to other integrations, so the scanner registers
+ its own device instead of attaching to another config entry's split.
"""
mac = TEST_MAC_ADDRESS
other_entry_1 = MockConfigEntry(domain="other_1")
@@ -1696,10 +1702,170 @@ async def test_scanner_entity_composite_device_without_own_split(
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
+ # The scanner registers its own device instead of using a foreign split
entity_entry = entity_registry.async_get("device_tracker.composite_scanner")
assert entity_entry is not None
- assert entity_entry.device_id is None
+ assert entity_entry.device_id is not None
+ own_device = device_registry.async_get(entity_entry.device_id)
+ assert own_device is not None
+ assert own_device.config_entry_id == config_entry.entry_id
+
+
+async def test_scanner_entity_attaches_to_own_device(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test the scanner reuses its config entry's device when several share the MAC."""
+ mac = TEST_MAC_ADDRESS
+ other_entry = MockConfigEntry(domain="other")
+ other_entry.add_to_hass(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=other_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ identifiers={("other", "x")},
+ )
+ own_device = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ )
+
+ scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner")
+ scanner_entity.entity_id = "device_tracker.shared_mac_scanner"
+ await create_mock_platform(hass, config_entry, [scanner_entity])
+
+ entity_entry = entity_registry.async_get("device_tracker.shared_mac_scanner")
+ assert entity_entry is not None
+ assert entity_entry.device_id == own_device.id
+
+
+@pytest.mark.parametrize(
+ ("hostname", "expected_device_name"),
+ [
+ pytest.param("tracked-host", "tracked-host", id="hostname"),
+ pytest.param(None, TEST_MAC_ADDRESS, id="no-hostname-falls-back-to-mac"),
+ ],
+)
+async def test_scanner_entity_registers_own_device(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+ hostname: str | None,
+ expected_device_name: str,
+) -> None:
+ """Test the scanner registers its own device when the MAC is known elsewhere."""
+ mac = TEST_MAC_ADDRESS
+ foreign_ids = set()
+ for domain in ("other_1", "other_2"):
+ entry = MockConfigEntry(domain=domain)
+ entry.add_to_hass(hass)
+ foreign_ids.add(
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ identifiers={(domain, "x")},
+ ).id
+ )
+
+ scanner_entity = MockScannerEntity(
+ mac_address=mac, hostname=hostname, unique_id=f"{mac}_scanner"
+ )
+ scanner_entity.entity_id = "device_tracker.shared_mac_scanner"
+ await create_mock_platform(hass, config_entry, [scanner_entity])
+
+ entity_entry = entity_registry.async_get("device_tracker.shared_mac_scanner")
+ assert entity_entry is not None
+ assert entity_entry.device_id is not None
+ assert entity_entry.device_id not in foreign_ids
+ own_device = device_registry.async_get(entity_entry.device_id)
+ assert own_device is not None
+ assert own_device.config_entry_id == config_entry.entry_id
+ assert own_device.connections == {(dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))}
+ assert own_device.name == expected_device_name
+
+
+async def test_scanner_entity_own_device_in_subentry(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test the scanner's own device is created in the entity's config subentry."""
+ mac = TEST_MAC_ADDRESS
+ subentry_id = "mock-subentry-id"
+ config_entry = MockConfigEntry(
+ domain=TEST_DOMAIN,
+ subentries_data=(
+ ConfigSubentryData(
+ data={},
+ subentry_id=subentry_id,
+ subentry_type="test",
+ title="Mock subentry",
+ unique_id=None,
+ ),
+ ),
+ )
+ config_entry.add_to_hass(hass)
+ other_entry = MockConfigEntry(domain="other")
+ other_entry.add_to_hass(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=other_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ identifiers={("other", "x")},
+ )
+
+ scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner")
+ scanner_entity.entity_id = "device_tracker.subentry_scanner"
+ await create_mock_platform(
+ hass, config_entry, [scanner_entity], config_subentry_id=subentry_id
+ )
+
+ entity_entry = entity_registry.async_get("device_tracker.subentry_scanner")
+ assert entity_entry is not None
+ assert entity_entry.config_subentry_id == subentry_id
+ assert entity_entry.device_id is not None
+ own_device = device_registry.async_get(entity_entry.device_id)
+ assert own_device is not None
+ assert own_device.config_entry_id == config_entry.entry_id
+ assert own_device.config_subentry_id == subentry_id
+
+
+async def test_scanner_entity_prunes_composite_identifiers(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test re-registration prunes composite relics from the scanner's split device.
+
+ Splits of a pre-migration composite keep the identifiers and connections copied
+ from the composite until the owning config entry re-registers the device; the
+ scanner's registration provides only the MAC connection, dropping the rest.
+ """
+ mac = TEST_MAC_ADDRESS
+ own_split = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ identifiers={("other", "copied-identifier")},
+ )
+ device_registry.devices[own_split.id] = attr.evolve(
+ own_split,
+ composite_device_id="composite00000000000000000000000",
+ has_composite_identifiers=True,
+ )
+
+ 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])
+
+ entity_entry = entity_registry.async_get("device_tracker.composite_scanner")
+ assert entity_entry is not None
+ assert entity_entry.device_id == own_split.id
+ device = device_registry.async_get(own_split.id)
+ assert device is not None
+ assert device.identifiers == set()
+ assert device.connections == {(dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))}
async def test_connected_device_registered(
diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py
index 2df119475e01..ed075aa58b6c 100644
--- a/tests/components/enphase_envoy/test_init.py
+++ b/tests/components/enphase_envoy/test_init.py
@@ -475,6 +475,39 @@ async def test_remove_config_entry_device(
assert response["success"]
+@pytest.mark.parametrize(
+ ("mock_envoy"),
+ [
+ "envoy_metered_batt_relay",
+ ],
+ indirect=["mock_envoy"],
+)
+@pytest.mark.usefixtures("mock_envoy")
+async def test_dry_contact_relay_via_device(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test the via_device chain relay -> Enpower -> Envoy is set up correctly."""
+ await setup_integration(hass, config_entry)
+
+ envoy_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "1234"), config_entry.entry_id
+ )
+ assert envoy_device is not None
+ enpower_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "654321"), config_entry.entry_id
+ )
+ assert enpower_device is not None
+ relay_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "NC1"), config_entry.entry_id
+ )
+ assert relay_device is not None
+
+ assert enpower_device.via_device_id == envoy_device.id
+ assert relay_device.via_device_id == enpower_device.id
+
+
@pytest.mark.parametrize("mock_envoy", ["envoy_acb_batt"], indirect=["mock_envoy"])
async def test_remove_config_entry_device_acb(
hass: HomeAssistant,
diff --git a/tests/components/fibaro/test_init.py b/tests/components/fibaro/test_init.py
index 330de74d6af4..e9adfa72c5ce 100644
--- a/tests/components/fibaro/test_init.py
+++ b/tests/components/fibaro/test_init.py
@@ -2,10 +2,12 @@
from unittest.mock import Mock, patch
+from homeassistant.components.fibaro.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
-from .conftest import init_integration
+from .conftest import TEST_SERIALNUMBER, init_integration
from tests.common import MockConfigEntry
@@ -29,3 +31,32 @@ async def test_unload_integration(
await hass.async_block_till_done()
# Assert
assert mock_fibaro_client.unregister_update_handler.call_count == 1
+
+
+async def test_load_integration_links_device_to_hub(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ mock_fibaro_client: Mock,
+ mock_config_entry: MockConfigEntry,
+ mock_light: Mock,
+ mock_room: Mock,
+) -> None:
+ """Test load integration sets via_device_id for a light device to the hub device."""
+ # Arrange
+ mock_fibaro_client.read_rooms.return_value = [mock_room]
+ mock_fibaro_client.read_devices.return_value = [mock_light]
+
+ # Act
+ with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]):
+ await init_integration(hass, mock_config_entry)
+
+ # Assert
+ hub_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, TEST_SERIALNUMBER), mock_config_entry.entry_id
+ )
+ light_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, mock_light.fibaro_id), mock_config_entry.entry_id
+ )
+ assert hub_device is not None
+ assert light_device is not None
+ assert light_device.via_device_id == hub_device.id
diff --git a/tests/components/freebox/test_init.py b/tests/components/freebox/test_init.py
index f1ef3e6f308d..60d5cf1f1b43 100644
--- a/tests/components/freebox/test_init.py
+++ b/tests/components/freebox/test_init.py
@@ -169,6 +169,27 @@ async def test_unique_id_migration(
)
+@pytest.mark.usefixtures("router")
+async def test_home_device_via_device(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test home devices are linked to the router device via via_device_id."""
+ entry = await setup_platform(hass, BINARY_SENSOR_DOMAIN)
+
+ router_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, MOCK_MAC), entry.entry_id
+ )
+ assert router_device is not None
+
+ pir_node_id = 26 # Détecteur from fixture
+ pir_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, pir_node_id), entry.entry_id
+ )
+ assert pir_device is not None
+ assert pir_device.via_device_id == router_device.id
+
+
async def test_home_device_label_sync(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py
index a65f6278e931..8d397de7a79a 100644
--- a/tests/components/google_generative_ai_conversation/test_conversation.py
+++ b/tests/components/google_generative_ai_conversation/test_conversation.py
@@ -274,10 +274,14 @@ async def test_function_call(
},
"inline_data": None,
"media_resolution": None,
+ "part_metadata": None,
"text": None,
"thought": None,
"thought_signature": None,
+ "tool_call": None,
+ "tool_response": None,
"video_metadata": None,
+ "audio_transcription": None,
}
# Test history conversion for multi-turn conversation
diff --git a/tests/components/growatt_server/test_init.py b/tests/components/growatt_server/test_init.py
index 2519ea019d32..f2499de6fd28 100644
--- a/tests/components/growatt_server/test_init.py
+++ b/tests/components/growatt_server/test_init.py
@@ -10,10 +10,10 @@ import pytest
import requests
from syrupy.assertion import SnapshotAssertion
+from homeassistant.components.growatt_server import _CACHED_APIS
from homeassistant.components.growatt_server.const import (
AUTH_API_TOKEN,
AUTH_PASSWORD,
- CACHED_API_KEY,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_PLANT_ID,
@@ -733,10 +733,8 @@ async def test_setup_reuses_cached_api_from_migration(
# This confirms setup did NOT resolve plant_id again (optimization working)
mock_growatt_classic_api.plant_list.assert_called_once_with(123456)
- # Verify the cached API was removed after use (should not be in hass.data anymore)
- assert f"{CACHED_API_KEY}{mock_config_entry.entry_id}" not in hass.data.get(
- DOMAIN, {}
- )
+ # Verify the cached API was removed after use (one-time handoff)
+ assert mock_config_entry.entry_id not in _CACHED_APIS
async def test_migrate_failure_returns_false(
diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py
index 61813cb1df55..2b9fd3aff814 100644
--- a/tests/components/guardian/conftest.py
+++ b/tests/components/guardian/conftest.py
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.guardian import CONF_UID, DOMAIN
-from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
+from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.json import JsonObjectType
@@ -97,10 +97,17 @@ def data_wifi_status_fixture() -> JsonObjectType:
return load_json_object_fixture("wifi_status_data.json", "guardian")
+@pytest.fixture(name="platforms")
+def platforms_fixture() -> list[Platform]:
+ """Define a platforms fixture."""
+ return []
+
+
@pytest.fixture(name="setup_guardian")
async def setup_guardian_fixture(
hass: HomeAssistant,
config: dict[str, Any],
+ platforms: list[Platform],
data_sensor_pair_dump: JsonObjectType,
data_sensor_pair_sensor: JsonObjectType,
data_sensor_paired_sensor_status: JsonObjectType,
@@ -150,7 +157,7 @@ async def setup_guardian_fixture(
),
patch(
"homeassistant.components.guardian.PLATFORMS",
- [],
+ platforms,
),
):
assert await async_setup_component(hass, DOMAIN, config)
diff --git a/tests/components/guardian/test_init.py b/tests/components/guardian/test_init.py
new file mode 100644
index 000000000000..e2550a6a5dc8
--- /dev/null
+++ b/tests/components/guardian/test_init.py
@@ -0,0 +1,45 @@
+"""Test the Elexa Guardian init module."""
+
+from datetime import timedelta
+
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+
+from homeassistant.components.guardian import CONF_UID, DOMAIN
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+
+@pytest.fixture(name="platforms")
+def platforms_fixture() -> list[Platform]:
+ """Override to exercise the paired sensor entity setup."""
+ return [Platform.SENSOR]
+
+
+async def test_paired_sensor_via_device_id(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ device_registry: dr.DeviceRegistry,
+ config_entry: MockConfigEntry,
+ setup_guardian: None, # relies on config_entry fixture
+) -> None:
+ """Test that a paired sensor is linked to the valve controller via via_device_id."""
+ # Advance time so the sensor pair dump coordinator refreshes and discovers the
+ # paired sensor from the fixture data:
+ freezer.tick(timedelta(seconds=30))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ valve_controller_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, config_entry.data[CONF_UID]), config_entry.entry_id
+ )
+ paired_sensor_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "AABBCCDDEEFF"), config_entry.entry_id
+ )
+
+ assert valve_controller_device is not None
+ assert paired_sensor_device is not None
+ assert paired_sensor_device.via_device_id == valve_controller_device.id
diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py
index 469197b54b15..38c2840bf9ad 100644
--- a/tests/components/habitica/test_init.py
+++ b/tests/components/habitica/test_init.py
@@ -179,3 +179,40 @@ async def test_remove_party_and_reload(
hass.states.get("notify.test_user_private_message_test_partymember_displayname")
is None
)
+
+
+@pytest.mark.usefixtures("habitica")
+async def test_device_via_device_links(
+ hass: HomeAssistant,
+ config_entry_with_subentry: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test the via_device links between user, party and party member devices."""
+ group_id = "1e87097c-4c03-4f8c-a475-67cc7da7f409"
+ member_id = "ffce870c-3ff3-4fa4-bad1-87612e52b8e7"
+
+ config_entry_with_subentry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
+
+ assert config_entry_with_subentry.state is ConfigEntryState.LOADED
+
+ unique_id = config_entry_with_subentry.unique_id
+ entry_id = config_entry_with_subentry.entry_id
+
+ user_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, unique_id), entry_id
+ )
+ assert user_device is not None
+ assert user_device.via_device_id is None
+
+ party_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{unique_id}_{group_id}"), entry_id
+ )
+ assert party_device is not None
+ assert party_device.via_device_id == user_device.id
+
+ member_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, member_id), entry_id
+ )
+ assert member_device is not None
+ assert member_device.via_device_id == party_device.id
diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py
index 3f09a5907ad0..98345add569a 100644
--- a/tests/components/here_travel_time/test_init.py
+++ b/tests/components/here_travel_time/test_init.py
@@ -1,6 +1,6 @@
"""The test for the HERE Travel Time integration."""
-from datetime import datetime
+from typing import Any
import pytest
@@ -32,18 +32,18 @@ from tests.common import MockConfigEntry
DEFAULT_OPTIONS,
{
CONF_ROUTE_MODE: ROUTE_MODE_FASTEST,
- CONF_DEPARTURE_TIME: datetime.now(), # pylint: disable=home-assistant-enforce-naive-now
+ CONF_DEPARTURE_TIME: "08:00:00",
},
{
CONF_ROUTE_MODE: ROUTE_MODE_FASTEST,
- CONF_ARRIVAL_TIME: datetime.now(), # pylint: disable=home-assistant-enforce-naive-now
+ CONF_ARRIVAL_TIME: "08:00:00",
},
{
CONF_ROUTE_MODE: ROUTE_MODE_FASTEST,
},
],
)
-async def test_unload_entry(hass: HomeAssistant, options) -> None:
+async def test_unload_entry(hass: HomeAssistant, options: dict[str, Any]) -> None:
"""Test that unloading an entry works."""
entry = MockConfigEntry(
domain=DOMAIN,
diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py
index 45eb0e76e67c..d0ba96fe0354 100644
--- a/tests/components/hikvision/test_binary_sensor.py
+++ b/tests/components/hikvision/test_binary_sensor.py
@@ -147,18 +147,23 @@ async def test_binary_sensor_nvr_device(
await setup_integration(hass, mock_config_entry)
- # Verify NVR channel devices are created with via_device linking
+ # Verify NVR channel devices are linked to the NVR device via via_device_id
+ nvr_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, TEST_DEVICE_ID), mock_config_entry.entry_id
+ )
+ assert nvr_device is not None
+
channel_1_device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{TEST_DEVICE_ID}_1")}
)
assert channel_1_device is not None
- assert channel_1_device.via_device_id is not None
+ assert channel_1_device.via_device_id == nvr_device.id
channel_2_device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{TEST_DEVICE_ID}_2")}
)
assert channel_2_device is not None
- assert channel_2_device.via_device_id is not None
+ assert channel_2_device.via_device_id == nvr_device.id
# Verify sensors are created (entity IDs depend on translation loading)
states = hass.states.async_entity_ids("binary_sensor")
diff --git a/tests/components/hive/test_init.py b/tests/components/hive/test_init.py
index 86eacdd1d638..2e505af81b3f 100644
--- a/tests/components/hive/test_init.py
+++ b/tests/components/hive/test_init.py
@@ -1,5 +1,6 @@
"""Tests for the Hive integration __init__."""
+from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant.components.hive.const import DOMAIN
@@ -32,15 +33,36 @@ _HUB_BASE = {
},
}
+_CHILD_BINARY_SENSOR = {
+ "device_id": "hive-child-id",
+ "hiveID": "hive-child-id",
+ "hiveName": "Hive Hub Connectivity",
+ "haName": "Hub Connectivity",
+ "device_name": "Hive Hub",
+ "hiveType": "Connectivity",
+ "parentDevice": "hive-hub-id",
+ "deviceData": {
+ "model": "Hub",
+ "version": "1.2.3",
+ "manufacturer": "Hive",
+ "online": True,
+ },
+ "status": {"state": True},
+}
-def _make_mock_hive(hub_extra: dict) -> MagicMock:
+
+def _make_mock_hive(
+ hub_extra: dict, extra_devices: dict[str, list[dict[str, Any]]] | None = None
+) -> MagicMock:
"""Return a mocked Hive instance.
startSession returns a minimal devices dict.
"""
hub_data = {**_HUB_BASE, **hub_extra}
+ devices = {"parent": [hub_data], **(extra_devices or {})}
mock_hive = MagicMock()
- mock_hive.session.startSession = AsyncMock(return_value={"parent": [hub_data]})
+ mock_hive.session.startSession = AsyncMock(return_value=devices)
+ mock_hive.session.deviceList = devices
return mock_hive
@@ -54,12 +76,9 @@ async def test_hub_device_registers_mac_connection(
mock_hive = _make_mock_hive({"macAddress": "00:1C:2B:1C:2E:68"})
- with (
- patch(
- "homeassistant.components.hive.Hive",
- return_value=mock_hive,
- ),
- patch("homeassistant.components.hive.aiohttp_client.async_get_clientsession"),
+ with patch(
+ "homeassistant.components.hive.Hive",
+ return_value=mock_hive,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -79,12 +98,9 @@ async def test_hub_device_no_mac_connection_when_absent(
mock_hive = _make_mock_hive({}) # no macAddress key
- with (
- patch(
- "homeassistant.components.hive.Hive",
- return_value=mock_hive,
- ),
- patch("homeassistant.components.hive.aiohttp_client.async_get_clientsession"),
+ with patch(
+ "homeassistant.components.hive.Hive",
+ return_value=mock_hive,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -94,3 +110,36 @@ async def test_hub_device_no_mac_connection_when_absent(
assert not any(
conn_type == dr.CONNECTION_NETWORK_MAC for conn_type, _ in device.connections
)
+
+
+async def test_child_device_links_to_hub_via_device_id(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """A child device's via_device_id should point at the hub device's id."""
+ entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA)
+ entry.add_to_hass(hass)
+
+ mock_hive = _make_mock_hive(
+ {"macAddress": "00:1C:2B:1C:2E:68"},
+ {"binary_sensor": [_CHILD_BINARY_SENSOR], "sensor": []},
+ )
+ mock_hive.session.updateData = AsyncMock()
+ mock_hive.sensor.getSensor = AsyncMock(side_effect=lambda device: device)
+
+ with patch(
+ "homeassistant.components.hive.Hive",
+ return_value=mock_hive,
+ ):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ hub_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "hive-hub-id"), entry.entry_id
+ )
+ child_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "hive-child-id"), entry.entry_id
+ )
+ assert hub_device is not None
+ assert child_device is not None
+ assert child_device.via_device_id == hub_device.id
diff --git a/tests/components/homee/test_init.py b/tests/components/homee/test_init.py
index b601b4de294e..b70491d3bbc1 100644
--- a/tests/components/homee/test_init.py
+++ b/tests/components/homee/test_init.py
@@ -99,6 +99,34 @@ async def test_general_data(
assert device == snapshot
+async def test_via_device_id(
+ hass: HomeAssistant,
+ mock_homee: MagicMock,
+ mock_config_entry: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test node device is linked to the hub device via via_device_id."""
+ mock_homee.nodes = [
+ build_mock_node("cover_with_position_slats.json"),
+ build_mock_node("homee.json"),
+ ]
+ mock_homee.get_node_by_id = lambda node_id: (
+ mock_homee.nodes[0] if node_id == 3 else mock_homee.nodes[1]
+ )
+ await setup_integration(hass, mock_config_entry)
+
+ hub = device_registry.async_get_device_by_identifier(
+ (DOMAIN, HOMEE_ID), mock_config_entry.entry_id
+ )
+ device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{HOMEE_ID}-3"), mock_config_entry.entry_id
+ )
+
+ assert hub is not None
+ assert device is not None
+ assert device.via_device_id == hub.id
+
+
async def test_software_version(
hass: HomeAssistant,
mock_homee: MagicMock,
diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py
index 49d7f800ea5c..0dc457147e3d 100644
--- a/tests/components/homematicip_cloud/test_device.py
+++ b/tests/components/homematicip_cloud/test_device.py
@@ -4,6 +4,7 @@ from unittest.mock import patch
from homematicip.base.enums import EventType
+from homeassistant.components.homematicip_cloud import DOMAIN
from homeassistant.components.homematicip_cloud.hap import HomematicipHAP
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
@@ -296,3 +297,36 @@ async def test_hmip_multi_area_device(
# get the hap
hap_device = device_registry.async_get(device.via_device_id)
assert hap_device.name == "Home"
+
+
+async def test_hmip_child_device_links_to_access_point(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+ default_mock_hap_factory: HomeFactory,
+) -> None:
+ """Test a child device links back to the access point via via_device_id."""
+ entity_id = "light.treppe_ch"
+ entity_name = "Treppe CH"
+ device_model = "HmIP-BSL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Treppe"]
+ )
+
+ ha_state, _hmip_device = get_and_check_entity_basics(
+ hass, mock_hap, entity_id, entity_name, device_model
+ )
+ assert ha_state
+
+ entity = entity_registry.async_get(entity_id)
+ assert entity is not None
+
+ child_device = device_registry.async_get(entity.device_id)
+ assert child_device is not None
+
+ access_point_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, mock_hap.home.id), mock_hap.config_entry.entry_id
+ )
+ assert access_point_device is not None
+
+ assert child_device.via_device_id == access_point_device.id
diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py
index 85a73db8fdeb..1fa4b859097e 100644
--- a/tests/components/homematicip_cloud/test_hap.py
+++ b/tests/components/homematicip_cloud/test_hap.py
@@ -91,9 +91,13 @@ async def test_hap_setup_works(hass: HomeAssistant) -> None:
domain=DOMAIN,
data={HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"},
)
- home = Mock()
+ entry.add_to_hass(hass)
+ home = Mock(id="ABC123", label="Home")
hap = HomematicipHAP(hass, entry)
- with patch.object(hap, "get_hap", return_value=home):
+ with (
+ patch.object(hap, "get_hap", return_value=home),
+ patch.object(hass.config_entries, "async_forward_entry_setups"),
+ ):
async with entry.setup_lock:
assert await hap.async_setup()
@@ -133,11 +137,13 @@ async def test_hap_reset_unloads_entry_if_setup(
assert config_entries[0].state is ConfigEntryState.NOT_LOADED
+@pytest.mark.usefixtures("simple_mock_home")
async def test_hap_create(
- hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home
+ hass: HomeAssistant, hmip_config_entry: MockConfigEntry
) -> None:
"""Mock AsyncHome to execute get_hap."""
hass.config.components.add(DOMAIN)
+ hmip_config_entry.add_to_hass(hass)
hap = HomematicipHAP(hass, hmip_config_entry)
assert hap
with (
@@ -146,6 +152,7 @@ async def test_hap_create(
return_value=ConnectionContext(),
),
patch.object(hap, "async_connect"),
+ patch.object(hass.config_entries, "async_forward_entry_setups"),
):
async with hmip_config_entry.setup_lock:
assert await hap.async_setup()
diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py
index 3eba5a9b0dc7..51fc8cd4cd60 100644
--- a/tests/components/homewizard/test_init.py
+++ b/tests/components/homewizard/test_init.py
@@ -14,6 +14,7 @@ from homeassistant.components.homewizard.const import (
battery_mode_cloud_issue_id,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
+from homeassistant.const import Platform
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, issue_registry as ir
@@ -324,3 +325,35 @@ async def test_battery_cloud_issue_stale_issue_cleared_on_reload(
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None # pylint: disable=home-assistant-tests-registry-fixtures
+
+
+async def test_main_device_registered_before_platform_forwarding(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ mock_config_entry: MockConfigEntry,
+ mock_homewizardenergy: MagicMock,
+) -> None:
+ """Test the main device is registered before platforms are set up.
+
+ Restricting setup to the sensor platform removes the button platform,
+ whose main-device entity would otherwise register the main device
+ before the external sensor needs it, masking a missing up-front
+ registration.
+ """
+ mock_config_entry.add_to_hass(hass)
+ with patch("homeassistant.components.homewizard.PLATFORMS", [Platform.SENSOR]):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ main_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "5c2fafabcdef"), mock_config_entry.entry_id
+ )
+ assert main_device is not None
+
+ gas_meter_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "gas_meter_G001"), mock_config_entry.entry_id
+ )
+ assert gas_meter_device is not None
+ assert gas_meter_device.via_device_id == main_device.id
diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py
index a256bcde4baa..cd823934c95d 100644
--- a/tests/components/homewizard/test_sensor.py
+++ b/tests/components/homewizard/test_sensor.py
@@ -7,13 +7,13 @@ from homewizard_energy.models import CombinedModels, Measurement, State, System
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.homewizard.const import UPDATE_INTERVAL
+from homeassistant.components.homewizard.const import DOMAIN, UPDATE_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.util import dt as dt_util
-from tests.common import async_fire_time_changed
+from tests.common import MockConfigEntry, async_fire_time_changed
pytestmark = [
pytest.mark.usefixtures("init_integration"),
@@ -393,6 +393,24 @@ async def test_sensors(
assert snapshot(name=f"{entity_id}:device-registry") == device_entry
+async def test_external_device_via_device_id(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ init_integration: MockConfigEntry,
+) -> None:
+ """Test an external device is linked to the main device via via_device_id."""
+ main_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "5c2fafabcdef"), init_integration.entry_id
+ )
+ assert main_device is not None
+
+ gas_meter_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "gas_meter_G001"), init_integration.entry_id
+ )
+ assert gas_meter_device is not None
+ assert gas_meter_device.via_device_id == main_device.id
+
+
@pytest.mark.parametrize(
("device_fixture", "entity_ids"),
[
diff --git a/tests/components/hue/test_device_trigger_v1.py b/tests/components/hue/test_device_trigger_v1.py
index 393b6f0a2998..df9ee53ba607 100644
--- a/tests/components/hue/test_device_trigger_v1.py
+++ b/tests/components/hue/test_device_trigger_v1.py
@@ -1,7 +1,8 @@
"""The tests for Philips Hue device triggers for V1 bridge."""
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
+import pytest
from pytest_unordered import unordered
from homeassistant.components import automation, hue
@@ -12,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
-from .conftest import setup_platform
+from .conftest import create_config_entry, setup_platform
from .test_sensor_v1 import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1
from tests.common import async_get_device_automations
@@ -181,3 +182,52 @@ async def test_if_fires_on_state_change(
await hass.async_block_till_done()
assert len(mock_bridge_v1.mock_requests) == 3
assert len(service_calls) == 1
+
+
+async def test_error_when_remote_is_gone_after_config_entry_loads(
+ hass: HomeAssistant,
+ mock_bridge_v1: Mock,
+ device_registry: dr.DeviceRegistry,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test a trigger for a remote that is no longer on the bridge is reported."""
+ mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE)
+ config_entry = create_config_entry(api_version=1)
+ config_entry.add_to_hass(hass)
+ gone_device = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(hue.DOMAIN, "00:00:00:00:00:00:00:00")},
+ )
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": hue.DOMAIN,
+ "device_id": gone_device.id,
+ "type": "remote_button_short_press",
+ "subtype": "button_4",
+ },
+ "action": {"service": "test.automation"},
+ }
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+
+ mock_bridge_v1.config_entry = config_entry
+ with (
+ patch.object(hue.migration, "is_v2_bridge", return_value=False),
+ patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge_v1),
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert (
+ f"Got error 'Device {gone_device.id} is not available on the Hue bridge' "
+ "when setting up triggers for automation 0" in caplog.text
+ )
diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py
index b89aa2ed91d3..1a7685884b41 100644
--- a/tests/components/hue/test_device_trigger_v2.py
+++ b/tests/components/hue/test_device_trigger_v2.py
@@ -1,22 +1,56 @@
"""The tests for Philips Hue device triggers for V2 bridge."""
-from unittest.mock import Mock
+from typing import Any
+from unittest.mock import Mock, patch
from aiohue.v2.models.button import ButtonEvent
+import pytest
from pytest_unordered import unordered
-from homeassistant.components import hue
-from homeassistant.components.device_automation import DeviceAutomationType
+from homeassistant.components import automation, hue
+from homeassistant.components.device_automation import (
+ DeviceAutomationType,
+ InvalidDeviceAutomationConfig,
+)
+from homeassistant.components.hue import device_trigger
from homeassistant.components.hue.v2.device import async_setup_devices
from homeassistant.components.hue.v2.hue_event import async_setup_hue_events
-from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ CONF_DEVICE_ID,
+ CONF_DOMAIN,
+ CONF_PLATFORM,
+ CONF_TYPE,
+ SERVICE_TURN_OFF,
+ Platform,
+)
+from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.trigger import TriggerInfo
+from homeassistant.setup import async_setup_component
from homeassistant.util.json import JsonArrayType
-from .conftest import setup_platform
+from .conftest import create_config_entry, setup_platform
-from tests.common import async_capture_events, async_get_device_automations
+from tests.common import (
+ MockConfigEntry,
+ async_capture_events,
+ async_get_device_automations,
+)
+
+WALL_SWITCH_DEVICE_ID = "3ff06175-29e8-44a8-8fe7-af591b0025da"
+WALL_SWITCH_BUTTON_PRESS = {
+ "button": {
+ "button_report": {
+ "event": "initial_press",
+ "updated": "2021-10-01T12:00:00Z",
+ }
+ },
+ "id": "c658d3d8-a013-4b81-8ac6-78b248537e70",
+ "metadata": {"control_id": 1},
+ "type": "button",
+}
async def test_hue_event(
@@ -149,3 +183,162 @@ async def test_get_triggers_for_removed_device(
hass, DeviceAutomationType.TRIGGER, orphaned_device.id
)
assert triggers == []
+
+
+async def test_if_fires_when_config_entry_loads_late(
+ hass: HomeAssistant,
+ mock_bridge_v2: Mock,
+ v2_resources_test_data: JsonArrayType,
+ device_registry: dr.DeviceRegistry,
+ service_calls: list[ServiceCall],
+) -> None:
+ """Test a trigger attached before the bridge is loaded still fires.
+
+ Regression test for https://github.com/home-assistant/core/issues/156390
+ """
+ await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
+ config_entry = create_config_entry(api_version=2)
+ config_entry.add_to_hass(hass)
+
+ # the device is restored from the registry while the bridge is still setting up
+ wall_switch_device = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(hue.DOMAIN, WALL_SWITCH_DEVICE_ID)},
+ )
+
+ assert await async_setup_component(
+ hass, automation.DOMAIN, _automation_config(wall_switch_device.id)
+ )
+ await hass.async_block_till_done()
+ assert config_entry.state is not ConfigEntryState.LOADED
+
+ await _load_bridge(hass, mock_bridge_v2, config_entry)
+
+ mock_bridge_v2.api.emit_event("update", WALL_SWITCH_BUTTON_PRESS)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert len(service_calls) == 1
+ assert service_calls[0].data["some"] == ButtonEvent.INITIAL_PRESS.value
+
+ # turning the automation off detaches the trigger again
+ await _turn_off_automation(hass)
+ service_calls.clear()
+ mock_bridge_v2.api.emit_event("update", WALL_SWITCH_BUTTON_PRESS)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert len(service_calls) == 0
+
+
+async def test_trigger_removed_before_config_entry_loads(
+ hass: HomeAssistant,
+ mock_bridge_v2: Mock,
+ v2_resources_test_data: JsonArrayType,
+ device_registry: dr.DeviceRegistry,
+ service_calls: list[ServiceCall],
+) -> None:
+ """Test a trigger removed while the bridge is still loading never fires."""
+ await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
+ config_entry = create_config_entry(api_version=2)
+ config_entry.add_to_hass(hass)
+ wall_switch_device = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(hue.DOMAIN, WALL_SWITCH_DEVICE_ID)},
+ )
+
+ assert await async_setup_component(
+ hass, automation.DOMAIN, _automation_config(wall_switch_device.id)
+ )
+ await hass.async_block_till_done()
+
+ await _turn_off_automation(hass)
+ service_calls.clear()
+
+ await _load_bridge(hass, mock_bridge_v2, config_entry)
+
+ mock_bridge_v2.api.emit_event("update", WALL_SWITCH_BUTTON_PRESS)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert len(service_calls) == 0
+
+
+async def test_attach_trigger_for_device_without_hue_bridge(
+ hass: HomeAssistant, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test attaching a trigger for a device that is not on any Hue bridge."""
+ other_entry = MockConfigEntry(domain="other")
+ other_entry.add_to_hass(hass)
+ device = device_registry.async_get_or_create(
+ config_entry_id=other_entry.entry_id,
+ identifiers={("other", "not-a-hue-device")},
+ )
+ trigger_info: TriggerInfo = {
+ "domain": automation.DOMAIN,
+ "name": "test",
+ "variables": None,
+ "trigger_data": {"id": "", "idx": "0", "alias": None},
+ }
+
+ with pytest.raises(InvalidDeviceAutomationConfig):
+ await device_trigger.async_attach_trigger(
+ hass,
+ {
+ CONF_PLATFORM: "device",
+ CONF_DOMAIN: hue.DOMAIN,
+ CONF_DEVICE_ID: device.id,
+ CONF_TYPE: ButtonEvent.INITIAL_PRESS.value,
+ "subtype": 1,
+ },
+ Mock(),
+ trigger_info,
+ )
+
+
+def _automation_config(device_id: str) -> dict[str, Any]:
+ """Return an automation config triggering on a button press of the device."""
+ return {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": hue.DOMAIN,
+ "device_id": device_id,
+ "type": ButtonEvent.INITIAL_PRESS.value,
+ "subtype": 1,
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": "{{ trigger.event.data.type }}"},
+ },
+ }
+ ]
+ }
+
+
+async def _load_bridge(
+ hass: HomeAssistant, mock_bridge: Mock, config_entry: MockConfigEntry
+) -> None:
+ """Set up a config entry that was already added to hass."""
+ mock_bridge.config_entry = config_entry
+ with (
+ patch.object(hue.migration, "is_v2_bridge", return_value=True),
+ patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge),
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+
+ assert config_entry.state is ConfigEntryState.LOADED
+ await async_setup_hue_events(mock_bridge)
+ await hass.async_block_till_done()
+
+
+async def _turn_off_automation(hass: HomeAssistant) -> None:
+ """Turn off the automation created by `_automation_config`."""
+ await hass.services.async_call(
+ automation.DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "automation.automation_0"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py
index 6afd89644a5e..b9c76fe351e0 100644
--- a/tests/components/incomfort/conftest.py
+++ b/tests/components/incomfort/conftest.py
@@ -80,15 +80,25 @@ def mock_entry_options() -> dict[str, Any] | None:
return None
+@pytest.fixture
+def mock_entry_unique_id() -> str | None:
+ """Mock config entry unique_id for fixture."""
+ return None
+
+
@pytest.fixture
def mock_config_entry(
hass: HomeAssistant,
mock_entry_data: dict[str, Any],
mock_entry_options: dict[str, Any],
+ mock_entry_unique_id: str | None,
) -> MockConfigEntry:
"""Mock a config entry setup for incomfort integration."""
entry = MockConfigEntry(
- domain=DOMAIN, data=mock_entry_data, options=mock_entry_options
+ domain=DOMAIN,
+ data=mock_entry_data,
+ options=mock_entry_options,
+ unique_id=mock_entry_unique_id,
)
entry.add_to_hass(hass)
return entry
diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py
index 3fdd0c891a5c..c7e61ba88038 100644
--- a/tests/components/incomfort/test_init.py
+++ b/tests/components/incomfort/test_init.py
@@ -34,6 +34,36 @@ async def test_setup_platforms(
assert mock_config_entry.state is ConfigEntryState.LOADED
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+@pytest.mark.parametrize("mock_entry_unique_id", ["00:04:a3:de:ad:ff"])
+async def test_device_via_device_links(
+ hass: HomeAssistant,
+ device_registry: DeviceRegistry,
+ mock_incomfort: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that child devices link to the gateway via via_device_id."""
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ gateway_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id
+ )
+ assert gateway_device is not None
+
+ boiler_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "c0ffeec0ffee"), mock_config_entry.entry_id
+ )
+ assert boiler_device is not None
+ assert boiler_device.via_device_id == gateway_device.id
+
+ thermostat_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "c0ffeec0ffee_1"), mock_config_entry.entry_id
+ )
+ assert thermostat_device is not None
+ assert thermostat_device.via_device_id == gateway_device.id
+
+
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
"mock_heater_status", [MOCK_HEATER_STATUS | {"serial_no": "c01d00c0ffee"}]
diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py
index bdb749836e27..682bbc2c3bc7 100644
--- a/tests/components/insteon/test_api_aldb.py
+++ b/tests/components/insteon/test_api_aldb.py
@@ -20,11 +20,13 @@ from homeassistant.components.insteon.api.aldb import (
TYPE,
)
from homeassistant.components.insteon.api.device import INSTEON_DEVICE_NOT_FOUND
+from homeassistant.components.insteon.const import DOMAIN
from homeassistant.core import HomeAssistant
+from .const import MOCK_USER_INPUT_PLM
from .mock_devices import MockDevices
-from tests.common import load_fixture
+from tests.common import MockConfigEntry, load_fixture
from tests.typing import MockHAClientWebSocket, WebSocketGenerator
@@ -38,6 +40,13 @@ async def _setup(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data: dict[str, Any]
) -> tuple[MockHAClientWebSocket, MockDevices]:
"""Set up tests."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data=MOCK_USER_INPUT_PLM,
+ options={},
+ )
+ config_entry.add_to_hass(hass)
ws_client = await hass_ws_client(hass)
devices = MockDevices()
await devices.async_load()
diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py
index 6f1a174f024a..11b5f4e03233 100644
--- a/tests/components/insteon/test_api_device.py
+++ b/tests/components/insteon/test_api_device.py
@@ -123,14 +123,33 @@ async def test_get_ha_device_name(
"""Test getting the HA device name from an Insteon address."""
_, devices, _, device_reg = await async_mock_setup(hass, hass_ws_client)
+ config_entry_id = hass.config_entries.async_entries(DOMAIN)[0].entry_id
+
+ # Register a colliding device sharing the same identifier but owned by a second
+ # config entry. The lookup must be scoped to the supplied config entry: an
+ # unscoped lookup could return either device, so returning the correct name for
+ # each config entry proves the config entry controls the lookup.
+ other_config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT_PLM)
+ other_config_entry.add_to_hass(hass)
+ device_reg.async_get_or_create(
+ config_entry_id=other_config_entry.entry_id,
+ identifiers={(DOMAIN, "11.11.11")},
+ name="Device 11.11.11 second entry",
+ )
with patch.object(insteon.api.device, "devices", devices):
- # Test a real HA and Insteon device
- name = await async_device_name(device_reg, "11.11.11")
+ # The scoped lookup returns the device owned by the supplied config entry
+ name = await async_device_name(device_reg, "11.11.11", config_entry_id)
assert name == "Device 11.11.11"
+ # The same identifier scoped to the second config entry returns its device
+ name = await async_device_name(
+ device_reg, "11.11.11", other_config_entry.entry_id
+ )
+ assert name == "Device 11.11.11 second entry"
+
# Test no HA or Insteon device
- name = await async_device_name(device_reg, "BB.BB.BB")
+ name = await async_device_name(device_reg, "BB.BB.BB", config_entry_id)
assert name == ""
diff --git a/tests/components/isy994/test_init.py b/tests/components/isy994/test_init.py
index 2cd003ce5d83..59d724cb43fc 100644
--- a/tests/components/isy994/test_init.py
+++ b/tests/components/isy994/test_init.py
@@ -1,5 +1,7 @@
"""Test the Universal Devices ISY/IoX integration init."""
+from collections.abc import Callable
+from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -8,6 +10,7 @@ from homeassistant.components.isy994.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
@@ -71,3 +74,30 @@ async def test_setup_forwards_verify_ssl_to_pyisy(
assert entry.state is ConfigEntryState.LOADED
assert isy_constructor.call_args.kwargs["verify_ssl"] is verify_ssl
+
+
+async def test_node_device_linked_to_isy_device(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ mock_config_entry: MockConfigEntry,
+ mock_isy: MagicMock,
+ mock_node: Callable[..., Any],
+) -> None:
+ """Test a root node's device is linked to the ISY device via via_device_id."""
+ mock_config_entry.add_to_hass(hass)
+
+ node = mock_node(mock_isy, "22 22 22 1", "Test Node", "GenericNode")
+ mock_isy.nodes.__iter__.return_value = [("Test Node", node)]
+
+ assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ isy_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, mock_isy.uuid), mock_config_entry.entry_id
+ )
+ node_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{mock_isy.uuid}_{node.address}"), mock_config_entry.entry_id
+ )
+ assert isy_device is not None
+ assert node_device is not None
+ assert node_device.via_device_id == isy_device.id
diff --git a/tests/components/litejet/test_init.py b/tests/components/litejet/test_init.py
index 9232e9fc8af9..1948b968bbe8 100644
--- a/tests/components/litejet/test_init.py
+++ b/tests/components/litejet/test_init.py
@@ -1,7 +1,10 @@
"""The tests for the litejet component."""
+import pytest
+
from homeassistant.components.litejet.const import DOMAIN
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
from . import async_init_integration
@@ -13,6 +16,31 @@ async def test_setup_with_no_config(hass: HomeAssistant) -> None:
assert DOMAIN not in hass.data
+@pytest.mark.usefixtures("mock_litejet")
+async def test_child_devices_link_to_mcp(
+ hass: HomeAssistant, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test that light and switch devices are linked to the MCP parent device."""
+ entry = await async_init_integration(hass, use_switch=True)
+
+ parent = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{entry.entry_id}_mcp"), entry.entry_id
+ )
+ assert parent is not None
+
+ light_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{entry.entry_id}_light_1"), entry.entry_id
+ )
+ assert light_device is not None
+ assert light_device.via_device_id == parent.id
+
+ switch_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{entry.entry_id}_keypad_101"), entry.entry_id
+ )
+ assert switch_device is not None
+ assert switch_device.via_device_id == parent.id
+
+
async def test_unload_entry(hass: HomeAssistant, mock_litejet) -> None:
"""Test being able to unload an entry."""
entry = await async_init_integration(hass, use_switch=True, use_scene=True)
diff --git a/tests/components/lookin/test_init.py b/tests/components/lookin/test_init.py
new file mode 100644
index 000000000000..7d3dd7bd6abb
--- /dev/null
+++ b/tests/components/lookin/test_init.py
@@ -0,0 +1,83 @@
+"""Tests for the lookin integration setup."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from homeassistant.components.lookin.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from . import DEVICE_ID, DEVICE_NAME, IP_ADDRESS, MODULE
+
+from tests.common import MockConfigEntry
+
+MEDIA_UUID = "9999"
+
+
+def _mocked_device() -> MagicMock:
+ device = MagicMock()
+ device.name = DEVICE_NAME
+ device.id = DEVICE_ID
+ # A model < 2 device has no meteo sensor, so the lookin device is only
+ # registered by async_setup_entry, not by a sibling sensor entity.
+ device.model = 1
+ device.firmware = "1.2.3"
+ return device
+
+
+def _mocked_remote() -> MagicMock:
+ remote = MagicMock()
+ remote.name = "Living Room TV"
+ remote.device_type = "TV"
+ remote.status = "1000"
+ remote.functions = []
+ return remote
+
+
+def _mocked_protocol(device: MagicMock, remote: MagicMock) -> MagicMock:
+ protocol = MagicMock()
+ protocol.get_info = AsyncMock(return_value=device)
+ protocol.get_devices = AsyncMock(return_value=[{"Type": "01", "UUID": MEDIA_UUID}])
+ protocol.get_remote = AsyncMock(return_value=remote)
+ protocol.get_media_sources = AsyncMock(return_value=[])
+ protocol.send_command = AsyncMock()
+ return protocol
+
+
+async def test_controlled_device_links_to_lookin_device(
+ hass: HomeAssistant, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test a controlled device links to the lookin device via via_device_id."""
+ device = _mocked_device()
+ remote = _mocked_remote()
+ protocol = _mocked_protocol(device, remote)
+
+ entry = MockConfigEntry(
+ domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=DEVICE_ID
+ )
+ entry.add_to_hass(hass)
+
+ subscriptions = MagicMock()
+ subscriptions.subscribe_event = MagicMock(return_value=MagicMock())
+
+ with (
+ patch(f"{MODULE}.LookInHttpProtocol", return_value=protocol),
+ patch(f"{MODULE}.LookinUDPSubscriptions", return_value=subscriptions),
+ patch(f"{MODULE}.start_lookin_udp", AsyncMock(return_value=MagicMock())),
+ ):
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state is ConfigEntryState.LOADED
+
+ lookin_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, DEVICE_ID), entry.entry_id
+ )
+ assert lookin_device is not None
+
+ controlled_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, MEDIA_UUID), entry.entry_id
+ )
+ assert controlled_device is not None
+ assert controlled_device.via_device_id == lookin_device.id
diff --git a/tests/components/lupusec/test_init.py b/tests/components/lupusec/test_init.py
new file mode 100644
index 000000000000..ba6abf1df1e8
--- /dev/null
+++ b/tests/components/lupusec/test_init.py
@@ -0,0 +1,85 @@
+"""Tests for the Lupusec integration setup."""
+
+from unittest.mock import MagicMock, patch
+
+import lupupy.constants as CONST
+
+from homeassistant.components.lupusec.const import DOMAIN
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from tests.common import MockConfigEntry
+
+MOCK_DATA = {
+ CONF_HOST: "test-host.lan",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+}
+
+
+def _mock_lupusec_system() -> MagicMock:
+ """Return a mocked Lupusec system with an alarm and one binary sensor."""
+ system = MagicMock()
+ system.model = 2
+
+ alarm = MagicMock()
+ alarm.name = "Alarm Panel"
+ alarm.device_id = "0"
+ alarm.is_standby = True
+ alarm.is_away = False
+ alarm.is_home = False
+ alarm.is_alarm_triggered = False
+ system.get_alarm.return_value = alarm
+
+ sensor = MagicMock()
+ sensor.device_id = "sensor_1"
+ sensor.name = "Front Door"
+ sensor.type = "Türkontakt"
+ sensor.generic_type = "door"
+ sensor.is_on = False
+
+ system.get_devices.side_effect = lambda generic_type: (
+ [] if generic_type == CONST.TYPE_SWITCH else [sensor]
+ )
+ return system
+
+
+async def test_child_device_links_to_parent(
+ hass: HomeAssistant, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test the child device is linked to the alarm panel via via_device_id.
+
+ Only the binary_sensor platform is forwarded, so the alarm panel (parent)
+ device is not created by the sibling alarm_control_panel platform. The child
+ can therefore only resolve its via_device_id if the parent was registered at
+ setup, before the platforms were forwarded.
+ """
+ entry = MockConfigEntry(domain=DOMAIN, title=MOCK_DATA[CONF_HOST], data=MOCK_DATA)
+ entry.add_to_hass(hass)
+
+ with (
+ patch(
+ "homeassistant.components.lupusec.lupupy.Lupusec",
+ return_value=_mock_lupusec_system(),
+ ),
+ patch(
+ "homeassistant.components.lupusec.PLATFORMS",
+ [Platform.BINARY_SENSOR],
+ ),
+ ):
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ parent = device_registry.async_get_device_by_identifier(
+ (DOMAIN, entry.entry_id), entry.entry_id
+ )
+ assert parent is not None
+ assert parent.name == "Alarm Panel"
+ assert parent.model == "Lupusec-XT2"
+
+ child = device_registry.async_get_device_by_identifier(
+ (DOMAIN, "sensor_1"), entry.entry_id
+ )
+ assert child is not None
+ assert child.via_device_id == parent.id
diff --git a/tests/components/lutron/test_init.py b/tests/components/lutron/test_init.py
index e06dcb2404b2..b483cee9acd6 100644
--- a/tests/components/lutron/test_init.py
+++ b/tests/components/lutron/test_init.py
@@ -239,3 +239,69 @@ async def test_keypad_integer_to_uuid_migration(
device = device_registry.async_get_device(identifiers={(DOMAIN, new_unique_id)})
assert device is not None
assert device.name == "Test Keypad"
+
+
+async def test_via_device_id(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ mock_lutron: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that devices are linked to the main repeater via via_device_id."""
+ mock_config_entry.add_to_hass(hass)
+
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ controller_guid = mock_lutron.guid
+ repeater_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, controller_guid), mock_config_entry.entry_id
+ )
+ assert repeater_device is not None
+ assert repeater_device.via_device_id is None
+
+ light = mock_lutron.areas[0].outputs[0]
+ light_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{controller_guid}_{light.uuid}"), mock_config_entry.entry_id
+ )
+ assert light_device is not None
+ assert light_device.via_device_id == repeater_device.id
+
+ # The mocked keypad is not the main repeater's own keypad, so it links to
+ # the repeater via via_device_id like any other device.
+ keypad = mock_lutron.areas[0].keypads[0]
+ keypad_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{controller_guid}_{keypad.uuid}"), mock_config_entry.entry_id
+ )
+ assert keypad_device is not None
+ assert keypad_device.via_device_id == repeater_device.id
+
+
+async def test_via_device_id_main_repeater_keypad(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ mock_lutron: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that the main repeater's own keypad stays the root device."""
+ mock_config_entry.add_to_hass(hass)
+ mock_lutron.areas[0].keypads[0].type = "MAIN_REPEATER"
+
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ controller_guid = mock_lutron.guid
+ repeater_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, controller_guid), mock_config_entry.entry_id
+ )
+ assert repeater_device is not None
+ assert repeater_device.via_device_id is None
+
+ keypad = mock_lutron.areas[0].keypads[0]
+ keypad_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{controller_guid}_{keypad.uuid}"), mock_config_entry.entry_id
+ )
+ # A MAIN_REPEATER keypad shares the controller's identifier, so it
+ # resolves to the same root device instead of a separate child device.
+ assert keypad_device is not None
+ assert keypad_device.id == repeater_device.id
diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py
index 089ea2463d39..2b232d830173 100644
--- a/tests/components/lutron_caseta/test_diagnostics.py
+++ b/tests/components/lutron_caseta/test_diagnostics.py
@@ -231,7 +231,7 @@ async def test_diagnostics(
"model": "RRST-W3RL-XX (SunnataKeypad)",
"name": "Hallway Main Stairs Position 1 Keypad",
"suggested_area": "Hallway",
- "via_device": ["lutron_caseta", 1234],
+ "via_device_id": ANY,
},
"dr_device_id": ANY,
"lutron_device_id": "1355",
@@ -250,7 +250,7 @@ async def test_diagnostics(
"model": "PJ2-3BRL-GXX-X01 (Pico3ButtonRaiseLower)",
"name": "Dining Room Pico",
"suggested_area": "Dining Room",
- "via_device": ["lutron_caseta", 1234],
+ "via_device_id": ANY,
},
"dr_device_id": ANY,
"lutron_device_id": "9",
diff --git a/tests/components/lutron_caseta/test_init.py b/tests/components/lutron_caseta/test_init.py
index 7e509acbf62f..2882e25aeec0 100644
--- a/tests/components/lutron_caseta/test_init.py
+++ b/tests/components/lutron_caseta/test_init.py
@@ -5,8 +5,10 @@ from unittest.mock import patch
import pytest
from homeassistant.components import lutron_caseta
+from homeassistant.components.lutron_caseta.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from . import MockBridge, async_setup_integration, make_mock_entry
@@ -52,3 +54,22 @@ async def test_cannot_connect(
)
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert "Connection failed to 1.1.1.1" in caplog.text
+
+
+async def test_keypad_device_via_device_id(
+ hass: HomeAssistant, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test the keypad device is linked to the bridge device via via_device_id."""
+ mock_entry = await async_setup_integration(hass, MockBridge)
+
+ bridge_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, 1234), mock_entry.entry_id
+ )
+ keypad_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, 66286451), mock_entry.entry_id
+ )
+
+ assert bridge_device is not None
+ assert keypad_device is not None
+ assert keypad_device.via_device_id == bridge_device.id
+ assert bridge_device.via_device_id is None
diff --git a/tests/components/lyngdorf/test_init.py b/tests/components/lyngdorf/test_init.py
index c1fe914b75f2..27edd15e4f24 100644
--- a/tests/components/lyngdorf/test_init.py
+++ b/tests/components/lyngdorf/test_init.py
@@ -69,6 +69,23 @@ async def test_unload_entry(
assert init_integration.state is ConfigEntryState.NOT_LOADED
+async def test_zone_b_via_device_id(
+ init_integration: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test that Zone B's via_device_id points at the main device."""
+ assert init_integration.unique_id
+ main_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, init_integration.unique_id), init_integration.entry_id
+ )
+ zone_b_device = device_registry.async_get_device_by_identifier(
+ (DOMAIN, f"{init_integration.unique_id}_zone_b"), init_integration.entry_id
+ )
+ assert main_device is not None
+ assert zone_b_device is not None
+ assert zone_b_device.via_device_id == main_device.id
+
+
@pytest.mark.parametrize(
("serial", "expected_mac_connections"),
[
diff --git a/tests/components/lyric/test_sensor.py b/tests/components/lyric/test_sensor.py
index 7f74b5e8d63e..f01107a812a6 100644
--- a/tests/components/lyric/test_sensor.py
+++ b/tests/components/lyric/test_sensor.py
@@ -1,8 +1,23 @@
"""Tests for the Honeywell Lyric sensor platform."""
from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+from aiolyric import Lyric
+from aiolyric.objects.location import LyricLocation
+from aiolyric.objects.priority import LyricRoom
+
+from homeassistant.components.lyric.api import LyricLocalOAuth2Implementation
+from homeassistant.components.lyric.const import DOMAIN
from homeassistant.components.lyric.sensor import get_datetime_from_future_time
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from tests.common import MockConfigEntry
+
+_MAC = "AABBCCDDEEFF"
def test_get_datetime_from_future_time_none() -> None:
@@ -19,3 +34,92 @@ def test_get_datetime_from_future_time_valid() -> None:
"""Test that a valid time string returns a datetime."""
result = get_datetime_from_future_time("13:30:00")
assert isinstance(result, datetime)
+
+
+def _mock_lyric() -> MagicMock:
+ """Build a fake aiolyric Lyric client with one thermostat and one room accessory."""
+ client = MagicMock()
+ location = LyricLocation(
+ client,
+ {
+ "locationID": 1234,
+ "name": "Home",
+ "devices": [
+ # A thermostat with no device-level sensors: the sensor platform
+ # creates no thermostat entity, so the thermostat device can only
+ # come from the up-front registration in async_setup_entry.
+ {
+ "deviceID": f"LCC-{_MAC}",
+ "deviceClass": "Thermostat",
+ "macID": _MAC,
+ "name": "Thermostat",
+ "deviceModel": "T5-T6",
+ }
+ ],
+ },
+ )
+ room = LyricRoom(
+ {
+ "id": 1,
+ "roomName": "Living Room",
+ "roomAvgTemp": 21,
+ "roomAvgHumidity": 40,
+ "accessories": [{"id": 1, "type": "IndoorAirSensor", "temperature": 21}],
+ }
+ )
+
+ lyric = MagicMock(spec=Lyric)
+ lyric.get_locations = AsyncMock()
+ lyric.get_thermostat_rooms = AsyncMock()
+ lyric.locations = [location]
+ lyric.locations_dict = {1234: location}
+ lyric.rooms_dict = {_MAC: {1: room}}
+ return lyric
+
+
+async def test_accessory_links_to_thermostat_via_device(
+ hass: HomeAssistant, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test room accessory devices resolve the thermostat as their via_device."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "auth_implementation": DOMAIN,
+ "token": {
+ "access_token": "mock-access-token",
+ "refresh_token": "mock-refresh-token",
+ "expires_at": 9999999999,
+ "token_type": "Bearer",
+ },
+ },
+ )
+ entry.add_to_hass(hass)
+
+ implementation = MagicMock(spec=LyricLocalOAuth2Implementation)
+ implementation.client_id = "client-id"
+
+ with (
+ patch(
+ "homeassistant.helpers.config_entry_oauth2_flow."
+ "async_get_config_entry_implementation",
+ return_value=implementation,
+ ),
+ patch("homeassistant.components.lyric.PLATFORMS", [Platform.SENSOR]),
+ patch("homeassistant.components.lyric.Lyric", return_value=_mock_lyric()),
+ ):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state is ConfigEntryState.LOADED
+
+ thermostat = device_registry.async_get_device_by_identifier(
+ (dr.CONNECTION_NETWORK_MAC, _MAC), entry.entry_id
+ )
+ assert thermostat is not None
+
+ accessory = device_registry.async_get_device_by_identifier(
+ (f"{dr.CONNECTION_NETWORK_MAC}_room_accessory", f"{_MAC}_room1_accessory1"),
+ entry.entry_id,
+ )
+ assert accessory is not None
+ assert accessory.via_device_id == thermostat.id
diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py
index 2c80490865f0..9962d4308a0b 100644
--- a/tests/components/matter/common.py
+++ b/tests/components/matter/common.py
@@ -24,6 +24,7 @@ FIXTURES = [
"aqara_multi_state_p100",
"aqara_presence_fp300",
"aqara_sensor_w100",
+ "aqara_shutter_switch_h2",
"aqara_thermostat_w500",
"aqara_u200",
"atios_knx_bridge",
diff --git a/tests/components/matter/fixtures/nodes/aqara_shutter_switch_h2.json b/tests/components/matter/fixtures/nodes/aqara_shutter_switch_h2.json
new file mode 100644
index 000000000000..a9e894b2206b
--- /dev/null
+++ b/tests/components/matter/fixtures/nodes/aqara_shutter_switch_h2.json
@@ -0,0 +1,717 @@
+{
+ "node_id": 193,
+ "date_commissioned": "2026-07-30T15:08:38.765000",
+ "last_interview": "2026-07-30T15:08:50.683000",
+ "interview_version": 6,
+ "available": true,
+ "is_bridge": false,
+ "attributes": {
+ "0/29/65532": 1,
+ "0/29/4": [
+ {
+ "0": null,
+ "1": 7,
+ "2": 0
+ }
+ ],
+ "0/29/65533": 2,
+ "0/29/0": [
+ {
+ "0": 1296,
+ "1": 1
+ },
+ {
+ "0": 18,
+ "1": 1
+ },
+ {
+ "0": 22,
+ "1": 3
+ }
+ ],
+ "0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 53, 60, 62, 63, 144, 145, 156],
+ "0/29/2": [41],
+ "0/29/3": [1, 2, 3],
+ "0/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
+ "0/29/65529": [],
+ "0/29/65528": [],
+ "0/31/1": [],
+ "0/31/65533": 2,
+ "0/31/65532": 0,
+ "0/31/0": [
+ {
+ "1": 5,
+ "2": 2,
+ "3": [112233],
+ "4": null,
+ "254": 3
+ }
+ ],
+ "0/31/2": 4,
+ "0/31/3": 3,
+ "0/31/4": 4,
+ "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
+ "0/31/65529": [],
+ "0/31/65528": [],
+ "0/40/11": "20251103",
+ "0/40/12": "AK189",
+ "0/40/13": "https://www.aqara.com/en/products.html",
+ "0/40/14": "Aqara Shutter Switch H2 EU",
+ "0/40/15": "************",
+ "0/40/16": false,
+ "0/40/18": "************",
+ "0/40/21": 17039616,
+ "0/40/22": 1,
+ "0/40/65533": 3,
+ "0/40/0": 18,
+ "0/40/1": "Aqara",
+ "0/40/2": 4447,
+ "0/40/3": "Aqara Shutter Switch H2 EU",
+ "0/40/4": 4109,
+ "0/40/5": "",
+ "0/40/6": "**REDACTED**",
+ "0/40/7": 1000,
+ "0/40/8": "1.0.0.0",
+ "0/40/9": 1000,
+ "0/40/10": "1.0.0.0",
+ "0/40/19": {
+ "0": 3,
+ "1": 3
+ },
+ "0/40/65532": 0,
+ "0/40/65531": [
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22,
+ 65528, 65529, 65531, 65532, 65533
+ ],
+ "0/40/65529": [],
+ "0/40/65528": [],
+ "0/42/65533": 1,
+ "0/42/0": [],
+ "0/42/1": true,
+ "0/42/2": 1,
+ "0/42/3": null,
+ "0/42/65532": 0,
+ "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
+ "0/42/65529": [0],
+ "0/42/65528": [],
+ "0/48/65533": 1,
+ "0/48/65532": 0,
+ "0/48/0": 0,
+ "0/48/1": {
+ "0": 60,
+ "1": 900
+ },
+ "0/48/2": 0,
+ "0/48/3": 0,
+ "0/48/4": true,
+ "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
+ "0/48/65529": [0, 2, 4],
+ "0/48/65528": [1, 3, 5],
+ "0/49/65532": 2,
+ "0/49/2": 10,
+ "0/49/3": 20,
+ "0/49/9": 10,
+ "0/49/10": 4,
+ "0/49/65533": 2,
+ "0/49/0": 1,
+ "0/49/1": [
+ {
+ "0": "p0jbsOzJRNw=",
+ "1": true
+ }
+ ],
+ "0/49/4": true,
+ "0/49/5": 0,
+ "0/49/6": "p0jbsOzJRNw=",
+ "0/49/7": null,
+ "0/49/65531": [
+ 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533
+ ],
+ "0/49/65529": [0, 3, 4, 6, 8],
+ "0/49/65528": [1, 5, 7],
+ "0/51/3": 0,
+ "0/51/4": 1,
+ "0/51/5": [],
+ "0/51/6": [],
+ "0/51/7": [],
+ "0/51/65533": 2,
+ "0/51/65532": 0,
+ "0/51/0": [
+ {
+ "0": "MyHome",
+ "1": true,
+ "2": null,
+ "3": null,
+ "4": "hgG3BqEgn+8=",
+ "5": [],
+ "6": [
+ "/VX8YmMnAAEBfYZlMPDvkA==",
+ "/QANuACgAAAAAAD//gDUEw==",
+ "/QANuACgAADzd3MoSXUu0Q==",
+ "/oAAAAAAAACEAbcGoSCf7w=="
+ ],
+ "7": 4
+ }
+ ],
+ "0/51/1": 4,
+ "0/51/2": 437,
+ "0/51/8": false,
+ "0/51/65531": [
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533
+ ],
+ "0/51/65529": [0, 1],
+ "0/51/65528": [2],
+ "0/52/65532": 1,
+ "0/52/0": [
+ {
+ "0": 10,
+ "1": "Bluetoot",
+ "3": 1438
+ },
+ {
+ "0": 9,
+ "1": "Bluetoot",
+ "3": 107
+ },
+ {
+ "0": 2,
+ "1": "OT Stack",
+ "3": 692
+ },
+ {
+ "0": 3,
+ "1": "sys_evt",
+ "3": 1968
+ },
+ {
+ "0": 1,
+ "1": "Bluetoot",
+ "3": 306
+ },
+ {
+ "0": 8,
+ "1": "Tmr Svc",
+ "3": 801
+ },
+ {
+ "0": 7,
+ "1": "IDLE",
+ "3": 249
+ },
+ {
+ "0": 11,
+ "1": "app",
+ "3": 704
+ },
+ {
+ "0": 5,
+ "1": "CHIP",
+ "3": 217
+ }
+ ],
+ "0/52/1": 64024,
+ "0/52/2": 36696,
+ "0/52/3": 4294960819,
+ "0/52/65533": 1,
+ "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
+ "0/52/65529": [0],
+ "0/52/65528": [],
+ "0/53/65532": 15,
+ "0/53/6": 0,
+ "0/53/14": 1,
+ "0/53/15": 1,
+ "0/53/16": 0,
+ "0/53/17": 0,
+ "0/53/18": 1,
+ "0/53/19": 1,
+ "0/53/20": 0,
+ "0/53/21": 0,
+ "0/53/22": 458,
+ "0/53/23": 457,
+ "0/53/24": 1,
+ "0/53/25": 457,
+ "0/53/26": 457,
+ "0/53/27": 1,
+ "0/53/28": 458,
+ "0/53/29": 0,
+ "0/53/30": 0,
+ "0/53/31": 0,
+ "0/53/32": 0,
+ "0/53/33": 23,
+ "0/53/34": 0,
+ "0/53/35": 0,
+ "0/53/36": 14,
+ "0/53/37": 0,
+ "0/53/38": 0,
+ "0/53/39": 420,
+ "0/53/40": 249,
+ "0/53/41": 100,
+ "0/53/42": 348,
+ "0/53/43": 0,
+ "0/53/44": 0,
+ "0/53/45": 0,
+ "0/53/46": 0,
+ "0/53/47": 0,
+ "0/53/48": 0,
+ "0/53/49": 1,
+ "0/53/50": 0,
+ "0/53/51": 71,
+ "0/53/52": 0,
+ "0/53/53": 0,
+ "0/53/54": 0,
+ "0/53/55": 0,
+ "0/53/56": 65536,
+ "0/53/57": 0,
+ "0/53/58": 0,
+ "0/53/63": null,
+ "0/53/64": null,
+ "0/53/65533": 2,
+ "0/53/0": 25,
+ "0/53/1": 4,
+ "0/53/2": "MyHome",
+ "0/53/3": 4660,
+ "0/53/4": 12054125955590472924,
+ "0/53/5": "QP0ADbgAoAAA",
+ "0/53/7": [
+ {
+ "0": 7684895770532068360,
+ "1": 11,
+ "2": 13312,
+ "3": 345810,
+ "4": 17279,
+ "5": 3,
+ "6": -73,
+ "7": -73,
+ "8": 0,
+ "9": 0,
+ "10": true,
+ "11": true,
+ "12": true,
+ "13": false
+ },
+ {
+ "0": 8265194500311707858,
+ "1": 18,
+ "2": 30720,
+ "3": 60579,
+ "4": 19423,
+ "5": 3,
+ "6": -37,
+ "7": -36,
+ "8": 0,
+ "9": 0,
+ "10": true,
+ "11": true,
+ "12": true,
+ "13": false
+ },
+ {
+ "0": 4206032556233211940,
+ "1": 22,
+ "2": 53248,
+ "3": 232648,
+ "4": 45717,
+ "5": 2,
+ "6": -80,
+ "7": -80,
+ "8": 0,
+ "9": 0,
+ "10": true,
+ "11": true,
+ "12": true,
+ "13": false
+ }
+ ],
+ "0/53/8": [
+ {
+ "0": 7684895770532068360,
+ "1": 13312,
+ "2": 13,
+ "3": 53,
+ "4": 1,
+ "5": 3,
+ "6": 3,
+ "7": 11,
+ "8": true,
+ "9": true
+ },
+ {
+ "0": 0,
+ "1": 14336,
+ "2": 14,
+ "3": 53,
+ "4": 3,
+ "5": 0,
+ "6": 0,
+ "7": 11,
+ "8": true,
+ "9": false
+ },
+ {
+ "0": 0,
+ "1": 25600,
+ "2": 25,
+ "3": 53,
+ "4": 1,
+ "5": 0,
+ "6": 0,
+ "7": 26,
+ "8": true,
+ "9": false
+ },
+ {
+ "0": 0,
+ "1": 26624,
+ "2": 26,
+ "3": 53,
+ "4": 1,
+ "5": 0,
+ "6": 0,
+ "7": 11,
+ "8": true,
+ "9": false
+ },
+ {
+ "0": 8265194500311707858,
+ "1": 30720,
+ "2": 30,
+ "3": 53,
+ "4": 1,
+ "5": 3,
+ "6": 3,
+ "7": 18,
+ "8": true,
+ "9": true
+ },
+ {
+ "0": 0,
+ "1": 44032,
+ "2": 43,
+ "3": 53,
+ "4": 1,
+ "5": 0,
+ "6": 0,
+ "7": 20,
+ "8": true,
+ "9": false
+ },
+ {
+ "0": 0,
+ "1": 48128,
+ "2": 47,
+ "3": 53,
+ "4": 1,
+ "5": 0,
+ "6": 0,
+ "7": 9,
+ "8": true,
+ "9": false
+ },
+ {
+ "0": 0,
+ "1": 50176,
+ "2": 49,
+ "3": 53,
+ "4": 1,
+ "5": 0,
+ "6": 0,
+ "7": 18,
+ "8": true,
+ "9": false
+ },
+ {
+ "0": 4206032556233211940,
+ "1": 53248,
+ "2": 52,
+ "3": 53,
+ "4": 1,
+ "5": 2,
+ "6": 3,
+ "7": 22,
+ "8": true,
+ "9": true
+ },
+ {
+ "0": 0,
+ "1": 54272,
+ "2": 53,
+ "3": 63,
+ "4": 0,
+ "5": 0,
+ "6": 0,
+ "7": 182,
+ "8": true,
+ "9": false
+ }
+ ],
+ "0/53/9": 831392105,
+ "0/53/10": 68,
+ "0/53/11": 240,
+ "0/53/12": 81,
+ "0/53/13": 49,
+ "0/53/59": {
+ "0": 672,
+ "1": 32911
+ },
+ "0/53/60": "AB//4A==",
+ "0/53/61": {
+ "0": true,
+ "1": false,
+ "2": true,
+ "3": true,
+ "4": true,
+ "5": true,
+ "6": false,
+ "7": true,
+ "8": true,
+ "9": true,
+ "10": true,
+ "11": true
+ },
+ "0/53/62": [],
+ "0/53/65531": [
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
+ 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38,
+ 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
+ 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533
+ ],
+ "0/53/65529": [0],
+ "0/53/65528": [],
+ "0/60/65533": 1,
+ "0/60/65532": 0,
+ "0/60/0": 0,
+ "0/60/1": null,
+ "0/60/2": null,
+ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
+ "0/60/65529": [0, 2],
+ "0/60/65528": [],
+ "0/62/65533": 1,
+ "0/62/0": [
+ {
+ "1": "FTABAQQkAgE3AyQTAhgmBNXVHDAmBVUMykQ3BiQVAiQRwRgkBwEkCAEwCUEE1bzlh41SHAvtqjt1CCJh/L/YPbmjgQm3Jx2yJJ20w4WIKSvzgaew2G2+dTWDiuBb81+6LLqqHnM+IgD7pLyiiTcKNQEoARgkAgE2AwQCBAEYMAQU7oj9S/XvnOObKU8M+mbmT1VMv50wBRRa34d1hFPuca7UFWclq9cFnlPhShgwC0AgqBh37ol1axuvIRjJEf/zy1BkOa+/2wtJ3l/mahFhqXuh7dDav5z1d1x0Bz7puMY7L3RdO/4uWJEHxWEwB+CfGA==",
+ "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEAV5qZprx2HWOKSP2iCzsI7A0CHgZVtbwsQ/y4ssETfB9z00733STIN0AfD552Vi1h6fJSeEg0/pA82bJL/y0azcKNQEpARgkAmAwBBRa34d1hFPuca7UFWclq9cFnlPhSjAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQDD8OnB1NcHRxx387f9wZeFDYf32VZ3ZENQrlWBTQZqEKP+K6XjWmjTWttDEeW1kiNtB1T5ZBIaJUxVdqMuNQx8Y",
+ "254": 3
+ }
+ ],
+ "0/62/1": [
+ {
+ "1": "BG1FPaj8U/IZMJ0lkYRWnL7PNr67I7GmbKqrzOPKp92GWZkMLbxHskSpehAxMxW6nepQHWr+Eq9LLp7wy4CB7a4=",
+ "2": 4939,
+ "3": 2,
+ "4": 193,
+ "5": "ha-freebox",
+ "254": 3
+ }
+ ],
+ "0/62/2": 5,
+ "0/62/3": 3,
+ "0/62/4": [
+ "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=",
+ "FTABD38O1NiPyscyxScZaN7uECQCATcDJhSoQfl2GCYEIqqfLyYFImy36zcGJhSoQfl2GCQHASQIATAJQQT5WrI2v6EgLRXdxlmZLlXX3rxeBe1C3NN/x9QV0tMVF+gH/FPSyq69dZKuoyskx0UOHcN20wdPffFuqgy/4uiaNwo1ASkBGCQCYDAEFM8XoLF/WKnSeqflSO5TQBQz4ObIMAUUzxegsX9YqdJ6p+VI7lNAFDPg5sgYMAtAHTWpsQPPwqR9gCqBGcDbPu2gusKeVuytcD5v7qK1/UjVr2/WGjMw3SYM10HWKdPTQZa2f3JI3uxv1nFnlcQpDBg=",
+ "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEbUU9qPxT8hkwnSWRhFacvs82vrsjsaZsqqvM48qn3YZZmQwtvEeyRKl6EDEzFbqd6lAdav4Sr0sunvDLgIHtrjcKNQEpARgkAmAwBBRvaChVdZwDuXcf/oyr6vKPKCnGWzAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQLa3jnnqN0/o6VG8wM4V9FDzrgDfKPd5cn3BBz77K80Jzo/aNotaTNOa6zX//yIvOkBZfGyq1Dh1vXZ4g2NKcXoY"
+ ],
+ "0/62/5": 3,
+ "0/62/65532": 0,
+ "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533],
+ "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11],
+ "0/62/65528": [1, 3, 5, 8],
+ "0/63/65533": 2,
+ "0/63/65532": 0,
+ "0/63/0": [],
+ "0/63/1": [],
+ "0/63/2": 12,
+ "0/63/3": 3,
+ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
+ "0/63/65529": [0, 1, 3, 4],
+ "0/63/65528": [2, 5],
+ "0/144/65532": 2,
+ "0/144/4": null,
+ "0/144/5": null,
+ "0/144/65533": 1,
+ "0/144/0": 2,
+ "0/144/1": 2,
+ "0/144/2": [
+ {
+ "0": 5,
+ "1": false,
+ "2": 2200,
+ "3": 48000000,
+ "4": [
+ {
+ "0": 2200,
+ "1": 48000000,
+ "5": 0
+ }
+ ]
+ },
+ {
+ "0": 2,
+ "1": false,
+ "2": 20,
+ "3": 200000,
+ "4": [
+ {
+ "0": 20,
+ "1": 200000,
+ "5": 0
+ }
+ ]
+ }
+ ],
+ "0/144/8": null,
+ "0/144/65531": [0, 1, 2, 4, 5, 8, 65528, 65529, 65531, 65532, 65533],
+ "0/144/65529": [],
+ "0/144/65528": [],
+ "0/145/65532": 5,
+ "0/145/1": {
+ "0": 0,
+ "2": 845310100
+ },
+ "0/145/5": null,
+ "0/145/65533": 1,
+ "0/145/0": {
+ "0": 14,
+ "1": false,
+ "2": 0,
+ "3": 4611686018427387904
+ },
+ "0/145/65531": [0, 1, 3, 5, 65528, 65529, 65531, 65532, 65533],
+ "0/145/65529": [],
+ "0/145/65528": [],
+ "0/156/65532": 1,
+ "0/156/65533": 1,
+ "0/156/65531": [65528, 65529, 65531, 65532, 65533],
+ "0/156/65529": [],
+ "0/156/65528": [],
+ "1/3/65533": 5,
+ "1/3/0": 0,
+ "1/3/1": 2,
+ "1/3/65532": 0,
+ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
+ "1/3/65529": [0, 64],
+ "1/3/65528": [],
+ "1/4/65532": 1,
+ "1/4/65533": 4,
+ "1/4/0": 128,
+ "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533],
+ "1/4/65529": [0, 1, 2, 3, 4, 5],
+ "1/4/65528": [0, 1, 2, 3],
+ "1/29/65532": 1,
+ "1/29/4": [
+ {
+ "0": null,
+ "1": 8,
+ "2": 0
+ }
+ ],
+ "1/29/65533": 2,
+ "1/29/0": [
+ {
+ "0": 514,
+ "1": 3
+ }
+ ],
+ "1/29/1": [3, 4, 29, 258],
+ "1/29/2": [],
+ "1/29/3": [],
+ "1/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
+ "1/29/65529": [],
+ "1/29/65528": [],
+ "1/258/65532": 5,
+ "1/258/11": 10000,
+ "1/258/14": 10000,
+ "1/258/26": 0,
+ "1/258/65533": 5,
+ "1/258/0": 0,
+ "1/258/7": 9,
+ "1/258/10": 0,
+ "1/258/13": 0,
+ "1/258/23": 0,
+ "1/258/65531": [
+ 0, 7, 10, 11, 13, 14, 23, 26, 65528, 65529, 65531, 65532, 65533
+ ],
+ "1/258/65529": [0, 1, 2, 4, 5],
+ "1/258/65528": [],
+ "2/3/65533": 5,
+ "2/3/0": 0,
+ "2/3/1": 2,
+ "2/3/65532": 0,
+ "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
+ "2/3/65529": [0, 64],
+ "2/3/65528": [],
+ "2/29/65532": 1,
+ "2/29/4": [
+ {
+ "0": null,
+ "1": 8,
+ "2": 1
+ },
+ {
+ "0": null,
+ "1": 7,
+ "2": 1
+ }
+ ],
+ "2/29/65533": 2,
+ "2/29/0": [
+ {
+ "0": 15,
+ "1": 3
+ }
+ ],
+ "2/29/1": [3, 29, 59],
+ "2/29/2": [],
+ "2/29/3": [],
+ "2/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
+ "2/29/65529": [],
+ "2/29/65528": [],
+ "2/59/65532": 2,
+ "2/59/65533": 2,
+ "2/59/0": 2,
+ "2/59/1": 0,
+ "2/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
+ "2/59/65529": [],
+ "2/59/65528": [],
+ "3/3/65533": 5,
+ "3/3/0": 0,
+ "3/3/1": 2,
+ "3/3/65532": 0,
+ "3/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
+ "3/3/65529": [0, 64],
+ "3/3/65528": [],
+ "3/29/65532": 1,
+ "3/29/4": [
+ {
+ "0": null,
+ "1": 8,
+ "2": 1
+ },
+ {
+ "0": null,
+ "1": 7,
+ "2": 2
+ }
+ ],
+ "3/29/65533": 2,
+ "3/29/0": [
+ {
+ "0": 15,
+ "1": 3
+ }
+ ],
+ "3/29/1": [3, 29, 59],
+ "3/29/2": [],
+ "3/29/3": [],
+ "3/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
+ "3/29/65529": [],
+ "3/29/65528": [],
+ "3/59/65532": 2,
+ "3/59/65533": 2,
+ "3/59/0": 2,
+ "3/59/1": 0,
+ "3/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
+ "3/59/65529": [],
+ "3/59/65528": []
+ },
+ "attribute_subscriptions": []
+}
diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr
index 4e55d95fca8f..e8dd0b3a4279 100644
--- a/tests/components/matter/snapshots/test_binary_sensor.ambr
+++ b/tests/components/matter/snapshots/test_binary_sensor.ambr
@@ -203,6 +203,57 @@
'state': 'on',
})
# ---
+# name: test_binary_sensors[aqara_shutter_switch_h2][binary_sensor.aqara_shutter_switch_h2_eu_configuration_status-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': 'binary_sensor',
+ 'entity_category': ,
+ 'entity_id': 'binary_sensor.aqara_shutter_switch_h2_eu_configuration_status',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Configuration status',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Configuration status',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'config_status_operational',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-1-WindowCoveringConfigStatusOperational-258-7',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_binary_sensors[aqara_shutter_switch_h2][binary_sensor.aqara_shutter_switch_h2_eu_configuration_status-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'problem',
+ : 'Aqara Shutter Switch H2 EU Configuration status',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.aqara_shutter_switch_h2_eu_configuration_status',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
# name: test_binary_sensors[aqara_u200][binary_sensor.aqara_smart_lock_u200_actuator-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr
index c2dade92f269..f791eca115b1 100644
--- a/tests/components/matter/snapshots/test_button.ambr
+++ b/tests/components/matter/snapshots/test_button.ambr
@@ -662,6 +662,159 @@
'state': 'unknown',
})
# ---
+# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_1-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.aqara_shutter_switch_h2_eu_identify_1',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Identify (1)',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Identify (1)',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-1-IdentifyButton-3-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_1-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'identify',
+ : 'Aqara Shutter Switch H2 EU Identify (1)',
+ }),
+ 'context': ,
+ 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_1',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
+# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_2-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.aqara_shutter_switch_h2_eu_identify_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Identify (2)',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Identify (2)',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-2-IdentifyButton-3-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'identify',
+ : 'Aqara Shutter Switch H2 EU Identify (2)',
+ }),
+ 'context': ,
+ 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
+# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_3-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.aqara_shutter_switch_h2_eu_identify_3',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Identify (3)',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Identify (3)',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-3-IdentifyButton-3-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_3-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'identify',
+ : 'Aqara Shutter Switch H2 EU Identify (3)',
+ }),
+ 'context': ,
+ 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_3',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
# name: test_buttons[aqara_thermostat_w500][button.floor_heating_thermostat_identify_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr
index 1f322b6d5d91..c226415fc0c3 100644
--- a/tests/components/matter/snapshots/test_cover.ambr
+++ b/tests/components/matter/snapshots/test_cover.ambr
@@ -1,4 +1,58 @@
# serializer version: 1
+# name: test_covers[aqara_shutter_switch_h2][cover.aqara_shutter_switch_h2_eu-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': 'cover',
+ 'entity_category': None,
+ 'entity_id': 'cover.aqara_shutter_switch_h2_eu',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': ,
+ 'translation_key': None,
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-1-MatterCoverPositionAwareLift-258-10',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_covers[aqara_shutter_switch_h2][cover.aqara_shutter_switch_h2_eu-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 0,
+ : 'shade',
+ : 'Aqara Shutter Switch H2 EU',
+ : True,
+ : ,
+ }),
+ 'context': ,
+ 'entity_id': 'cover.aqara_shutter_switch_h2_eu',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'closed',
+ })
+# ---
# name: test_covers[eve_shutter][cover.eve_shutter_switch_20eci1701-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr
index 0ec4e27fff8a..f6e318209c24 100644
--- a/tests/components/matter/snapshots/test_event.ambr
+++ b/tests/components/matter/snapshots/test_event.ambr
@@ -194,6 +194,124 @@
'state': 'unknown',
})
# ---
+# name: test_events[aqara_shutter_switch_h2][event.aqara_shutter_switch_h2_eu_button_2-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': dict({
+ : list([
+ 'initial_press',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'event',
+ 'entity_category': None,
+ 'entity_id': 'event.aqara_shutter_switch_h2_eu_button_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Button (2)',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Button (2)',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'button',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-2-GenericSwitch-59-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_events[aqara_shutter_switch_h2][event.aqara_shutter_switch_h2_eu_button_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'button',
+ : None,
+ : list([
+ 'initial_press',
+ ]),
+ : 'Aqara Shutter Switch H2 EU Button (2)',
+ }),
+ 'context': ,
+ 'entity_id': 'event.aqara_shutter_switch_h2_eu_button_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
+# name: test_events[aqara_shutter_switch_h2][event.aqara_shutter_switch_h2_eu_button_3-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': dict({
+ : list([
+ 'initial_press',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'event',
+ 'entity_category': None,
+ 'entity_id': 'event.aqara_shutter_switch_h2_eu_button_3',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Button (3)',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Button (3)',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'button',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-3-GenericSwitch-59-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_events[aqara_shutter_switch_h2][event.aqara_shutter_switch_h2_eu_button_3-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'button',
+ : None,
+ : list([
+ 'initial_press',
+ ]),
+ : 'Aqara Shutter Switch H2 EU Button (3)',
+ }),
+ 'context': ,
+ 'entity_id': 'event.aqara_shutter_switch_h2_eu_button_3',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
# name: test_events[haojai_switch][event.hjmt_6b_button_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr
index 49e92ae6c5e1..fe243c28e258 100644
--- a/tests/components/matter/snapshots/test_sensor.ambr
+++ b/tests/components/matter/snapshots/test_sensor.ambr
@@ -3328,6 +3328,692 @@
'state': '2025-01-01T13:55:01+00:00',
})
# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_active_current-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': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_active_current',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Active current',
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 2,
+ }),
+ 'sensor.private': dict({
+ 'suggested_unit_of_measurement': ,
+ }),
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Active current',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'active_current',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ElectricalPowerMeasurementActiveCurrent-144-5',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_active_current-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'current',
+ : 'Aqara Shutter Switch H2 EU Active current',
+ : ,
+ : ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_active_current',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_boot_reason-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': dict({
+ : list([
+ 'unspecified',
+ 'power_on_reboot',
+ 'brown_out_reset',
+ 'software_watchdog_reset',
+ 'hardware_watchdog_reset',
+ 'software_update_completed',
+ 'software_reset',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_boot_reason',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Boot reason',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Boot reason',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'boot_reason',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-GeneralDiagnosticsBootReason-51-4',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_boot_reason-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'enum',
+ : 'Aqara Shutter Switch H2 EU Boot reason',
+ : list([
+ 'unspecified',
+ 'power_on_reboot',
+ 'brown_out_reset',
+ 'software_watchdog_reset',
+ 'hardware_watchdog_reset',
+ 'software_update_completed',
+ 'software_reset',
+ ]),
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_boot_reason',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'power_on_reboot',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_current_switch_position_2-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': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_current_switch_position_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Current switch position (2)',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Current switch position (2)',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'switch_current_position',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-2-SwitchCurrentPosition-59-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_current_switch_position_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Aqara Shutter Switch H2 EU Current switch position (2)',
+ : ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_current_switch_position_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_current_switch_position_3-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': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_current_switch_position_3',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Current switch position (3)',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Current switch position (3)',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'switch_current_position',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-3-SwitchCurrentPosition-59-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_current_switch_position_3-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Aqara Shutter Switch H2 EU Current switch position (3)',
+ : ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_current_switch_position_3',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_energy-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.aqara_shutter_switch_h2_eu_energy',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Energy',
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 3,
+ }),
+ 'sensor.private': dict({
+ 'suggested_unit_of_measurement': ,
+ }),
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Energy',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_energy-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'energy',
+ : 'Aqara Shutter Switch H2 EU Energy',
+ : ,
+ : ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_energy',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.0',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_reboot_count-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': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_reboot_count',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Reboot count',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Reboot count',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'reboot_count',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-GeneralDiagnosticsRebootCount-51-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_reboot_count-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Aqara Shutter Switch H2 EU Reboot count',
+ : ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_reboot_count',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '4',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_target_opening_position-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.aqara_shutter_switch_h2_eu_target_opening_position',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Target opening position',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Target opening position',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'window_covering_target_position',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-1-TargetPositionLiftPercent100ths-258-11',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_target_opening_position-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Aqara Shutter Switch H2 EU Target opening position',
+ : ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_target_opening_position',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_channel-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.aqara_shutter_switch_h2_eu_thread_channel',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Thread channel',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Thread channel',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'thread_channel',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ThreadDiagnosticsChannel-53-0',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_channel-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Aqara Shutter Switch H2 EU Thread channel',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_thread_channel',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '25',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_network_name-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.aqara_shutter_switch_h2_eu_thread_network_name',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Thread network name',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Thread network name',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'thread_network_name',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ThreadDiagnosticsNetworkName-53-2',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_network_name-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'Aqara Shutter Switch H2 EU Thread network name',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_thread_network_name',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'MyHome',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_routing_role-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': dict({
+ : list([
+ 'unspecified',
+ 'unassigned',
+ 'sleepy_end_device',
+ 'end_device',
+ 'reed',
+ 'router',
+ 'leader',
+ 'unknown',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_thread_routing_role',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Thread routing role',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Thread routing role',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'thread_routing_role',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ThreadDiagnosticsRoutingRole-53-1',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_routing_role-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'enum',
+ : 'Aqara Shutter Switch H2 EU Thread routing role',
+ : list([
+ 'unspecified',
+ 'unassigned',
+ 'sleepy_end_device',
+ 'end_device',
+ 'reed',
+ 'router',
+ 'leader',
+ 'unknown',
+ ]),
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_thread_routing_role',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'reed',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_uptime-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.aqara_shutter_switch_h2_eu_uptime',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Uptime',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Uptime',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'uptime',
+ 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-GeneralDiagnosticsUpTime-51-2',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_uptime-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ : 'uptime',
+ : 'Aqara Shutter Switch H2 EU Uptime',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_uptime',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '2025-01-01T13:52:43+00:00',
+ })
+# ---
+# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_voltage-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': ,
+ 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_voltage',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Voltage',
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 0,
+ }),
+ 'sensor.private': dict({
+ 'suggested_unit_of_measurement': ,
+ }),
+ }),
+ 'original_device_class':