Compare commits

...

53 Commits

Author SHA1 Message Date
Joostlek ff43e12449 Merge branch 'dev' into electrolux
# Conflicts:
#	requirements_test_all.txt
2026-05-21 14:22:34 +02:00
Markus Tuominen 654408cc76 Set _attr_has_entity_name on sonos SonosFavoritesEntity (#171678) 2026-05-21 13:42:21 +02:00
Max Michels 1f814faad8 Replace duplicate constants with homeassistant.const imports (#171702) 2026-05-21 13:36:14 +02:00
Markus Tuominen 6e00eecfcd Set _attr_has_entity_name on lunatone LunatoneLineBroadcastLight (#171682) 2026-05-21 13:19:42 +02:00
Robert Resch 8c8620c511 Add check requirements yanked and CVE check (#171641)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-21 12:54:15 +02:00
Wendelin cca8825ca5 Add comment optional attribute to automation items (#171091) 2026-05-21 12:52:54 +02:00
Max Michels 92fbcc29a5 Replace duplicate constants with homeassistant.const imports (#171700) 2026-05-21 12:51:19 +02:00
Shay Levy 1c28833f39 Fix LG WebOS TV translation placeholders mismatches (#171696) 2026-05-21 13:33:36 +03:00
Christian Lackas cfdef77222 homematicip_cloud: migrate entity names to has_entity_name (#169273) 2026-05-21 12:29:43 +02:00
epenet 49720475da Bump renault-api to 0.5.10 (#171692) 2026-05-21 12:16:29 +02:00
Markus Tuominen 7967b84cc6 Set _attr_has_entity_name on omie OMIEPriceSensor (#171671) 2026-05-21 12:14:03 +02:00
Markus Tuominen c715557813 Set _attr_has_entity_name on smartthings SmartThingsScene (#171672) 2026-05-21 12:10:06 +02:00
Markus Tuominen 79e5330782 Set _attr_has_entity_name on ekeybionyx EkeyEvent (#171668) 2026-05-21 12:03:01 +02:00
Max Michels 5210ca64b1 Replace duplicate constants with homeassistant.const imports (#171669) 2026-05-21 12:02:12 +02:00
Markus Tuominen 65283e3d77 Set _attr_has_entity_name on fitbit battery sensors (#171670) 2026-05-21 12:01:27 +02:00
mhuiskes 427cb9f8db Remove unnecessary intermediate variables in zeversolar diagnostics (#171691) 2026-05-21 11:55:34 +02:00
Erik Montnemery a09e042d42 Add test of FlowHandler show_advanced_options property (#171681) 2026-05-21 11:47:42 +02:00
Shay Levy 072e9b51a2 Fix Shelly translation placeholders mismatches (#171685) 2026-05-21 11:47:20 +02:00
Erik Montnemery b96342c4f3 Remove use of advanced mode from the knx integration (#171674) 2026-05-21 11:26:22 +02:00
Erik Montnemery 56eae8c808 Fix min value for music_assistant.get_library offset (#171664) 2026-05-21 10:25:08 +02:00
Erik Montnemery 9fbdf86104 Rename advanced options section to additional options in opendisplay service actions (#171452) 2026-05-21 10:18:31 +02:00
Jan Bouwhuis 8ff5da59c4 Fix hardcoded exception strings in incomfort (#171616) 2026-05-21 10:09:04 +02:00
Andres Ruiz 298f4f8ed0 Remove National Grid US virtual integration (#171204) 2026-05-21 09:53:59 +02:00
Willem-Jan van Rootselaar 6fdc0bb90b Fix bsblan set data error translation (#171529) 2026-05-21 09:51:54 +02:00
Mick Vleeshouwer 94c3ad2cb2 Bump pyOverkiz to 1.20.4 (#171626) 2026-05-21 09:50:54 +02:00
Martin Hjelmare d83d44648c Fix Home Connect exception translation placeholder mismatch (#171655) 2026-05-21 09:38:22 +02:00
Erik Montnemery 279b614b7c Remove advanced mode from music_assistant service actions (#171451) 2026-05-21 09:35:48 +02:00
Erik Montnemery 244dfe014a Remove advanced mode from mqtt service actions (#171448)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 09:33:10 +02:00
Markus Tuominen 6b379e50cf Add has-entity-name pylint quality scale checker (#171486) 2026-05-21 10:21:06 +03:00
epenet 1368cd15da Remove myself from samsungtv code-owners (#171654) 2026-05-21 08:54:59 +02:00
Franck Nijhof 8c8cc3acb9 Fix habitica ignoring zero values for interval and streak (#171468) 2026-05-21 08:06:08 +02:00
Franck Nijhof b0634bea35 Fix SmartThings crash when timestamp attribute is None (#171467) 2026-05-21 08:05:42 +02:00
Raphael Hehl 5ae31cad6f Fix unifiprotect exception translations (#171510) (#171619)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 08:04:21 +02:00
Brandon Rothweiler b45aaaa177 Update py-aosmith to 1.0.18 (#171647) 2026-05-21 07:42:07 +02:00
Jan-Philipp Benecke 6560496440 Add missing WebDAV exception translation (#171614) 2026-05-20 20:46:31 -04:00
Erwin Douna 489dda8efb SMA refactor to new pylint (#171630) 2026-05-20 20:45:39 -04:00
Alexey Masolov 30c942d139 Catch requests.Timeout and apply TIMEOUT constant across CalDAV integration (#171632)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 20:45:08 -04:00
On Freund c735e47e23 Bump pyrisco to 0.7.0 (#171644) 2026-05-20 20:42:29 -04:00
Robert Resch 3856405c72 Add aw check requirements async block check (#171642) 2026-05-21 01:28:32 +02:00
Robert Resch 323479ca44 Fix aw check requirements safe output (#171643) 2026-05-21 01:16:25 +02:00
Raphael Hehl c8bfe56975 Fix hardcoded exception strings in unifi_access (#171629)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-05-21 00:37:08 +02:00
A. Gideonse ab214b64f2 Implement final Indevolt exceptions translations (#171635) 2026-05-21 00:35:01 +02:00
Max Michels fea673d93a Replace duplicate constants with homeassistant.const imports (#171639) 2026-05-21 00:24:05 +02:00
Max Michels 5405151112 Replace duplicate constants with homeassistant.const imports (#171637) 2026-05-21 00:12:23 +02:00
Max Michels b3c210ef24 Replace duplicate constants with homeassistant.const imports (#171638) 2026-05-21 00:11:59 +02:00
Robert Resch 5f5d74cfbd Remove requirements_test_all file (#171530) 2026-05-20 23:54:31 +02:00
Josh Gustafson c188fdcc8b Clean up arcam_fmj config flow (#171161)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:58:10 +02:00
Michael Hansen a3b43fc19b Handle multiple intents in Wyoming conversation (#171615)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
2026-05-20 16:48:56 -04:00
Maciej Bieniek 894a68acb6 Fix media_image_hash and validate the MIME type in the Shelly media player (#171585) 2026-05-20 22:25:30 +02:00
Kamil Breguła 30bc3fc412 Bump wled to 0.23.0 and remove backoff exception (#171622) 2026-05-20 22:16:43 +02:00
Joost Lekkerkerker 7dfef5c82a Add icon translations to Electrolux (#170422) 2026-05-13 07:54:58 +02:00
Joostlek b75cd0f6a7 Merge branch 'dev' into electrolux 2026-05-12 16:50:46 +02:00
ferenc-fustos-electrolux 7859aba432 Add electrolux integration (#157176) 2026-05-12 12:40:40 +01:00
147 changed files with 7357 additions and 3559 deletions
-1
View File
@@ -19,7 +19,6 @@ machine/* linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true
requirements_test_all.txt linguist-generated=true
requirements_test_pre_commit.txt linguist-generated=true
script/hassfest/docker/Dockerfile linguist-generated=true
.github/workflows/*.lock.yml linguist-generated=true
+55 -23
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1ad29b8fb97f5df4466be54051779a3188f094d7efb041a8ed55211eab33c5f5","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
# 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":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
# ___ _ _
# / _ \ | | (_)
@@ -65,7 +65,9 @@ run-name: "Check requirements (AW)"
jobs:
activation:
needs: pre_activation
needs:
- extract_pr_number
- 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 &&
@@ -189,20 +191,20 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<system>
GH_AW_PROMPT_bb296919e461941b_EOF
GH_AW_PROMPT_198418d99edc7d5b_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_bb296919e461941b_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<safe-output-tools>
Tools: add_comment, missing_tool, missing_data, noop
</safe-output-tools>
GH_AW_PROMPT_bb296919e461941b_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<github-context>
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -231,12 +233,12 @@ jobs:
{{/if}}
</github-context>
GH_AW_PROMPT_bb296919e461941b_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
</system>
{{#runtime-import .github/workflows/check-requirements.md}}
GH_AW_PROMPT_bb296919e461941b_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -314,7 +316,9 @@ jobs:
retention-days: 1
agent:
needs: activation
needs:
- activation
- extract_pr_number
runs-on: ubuntu-latest
permissions:
actions: read
@@ -385,11 +389,6 @@ jobs:
name: check-requirements-deterministic
path: /tmp/gh-aw/deterministic
run-id: ${{ github.event.workflow_run.id }}
- if: github.event.workflow_run.conclusion == 'success'
name: Extract PR number from artifact
run: |-
PR=$(python3 -c 'import json,sys;print(json.load(open("/tmp/gh-aw/deterministic/results.json"))["pr_number"])')
echo "PR_NUMBER=${PR}" >> "${GITHUB_ENV}"
- name: Configure Git credentials
env:
@@ -454,15 +453,15 @@ jobs:
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_c09f5151c817ddfc_EOF'
{"add_comment":{"max":1,"target":"${{ env.PR_NUMBER }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
GH_AW_SAFE_OUTPUTS_CONFIG_c09f5151c817ddfc_EOF
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.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_627e06df80c4e5ad_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
{
"description_suffixes": {
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ env.PR_NUMBER }}. Supports reply_to_id for discussion threading."
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ needs.extract_pr_number.outputs.pr_number }}. Supports reply_to_id for discussion threading."
},
"repo_params": {},
"dynamic_tools": []
@@ -648,7 +647,7 @@ jobs:
mkdir -p /home/runner/.copilot
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
cat << GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
{
"mcpServers": {
"github": {
@@ -692,7 +691,7 @@ jobs:
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
}
}
GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
- name: Mount MCP servers as CLIs
id: mount-mcp-clis
continue-on-error: true
@@ -939,6 +938,7 @@ jobs:
- activation
- agent
- detection
- extract_pr_number
- safe_outputs
if: >
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
@@ -1283,6 +1283,37 @@ jobs:
}
}
extract_pr_number:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
outputs:
pr_number: ${{ steps.extract.outputs.pr_number }}
steps:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
run: |
# 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://}"
GH_HOST="${GH_HOST#http://}"
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
name: check-requirements-deterministic
path: /tmp/deterministic
run-id: ${{ github.event.workflow_run.id }}
- name: Extract PR number from artifact
id: extract
run: |
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
pre_activation:
runs-on: ubuntu-slim
outputs:
@@ -1321,6 +1352,7 @@ jobs:
- activation
- agent
- detection
- extract_pr_number
if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
runs-on: ubuntu-slim
permissions:
@@ -1393,7 +1425,7 @@ jobs:
GH_AW_ALLOWED_DOMAINS: "*.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"
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_API_URL: ${{ github.api_url }}
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ env.PR_NUMBER }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ needs.extract_pr_number.outputs.pr_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
+138 -13
View File
@@ -19,7 +19,30 @@ tools:
safe-outputs:
add-comment:
max: 1
target: "${{ env.PR_NUMBER }}"
target: "${{ needs.extract_pr_number.outputs.pr_number }}"
needs:
- extract_pr_number
jobs:
extract_pr_number:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
outputs:
pr_number: ${{ steps.extract.outputs.pr_number }}
steps:
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: check-requirements-deterministic
path: /tmp/deterministic
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract PR number from artifact
id: extract
run: |
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
@@ -32,11 +55,6 @@ steps:
path: /tmp/gh-aw/deterministic
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract PR number from artifact
if: github.event.workflow_run.conclusion == 'success'
run: |
PR=$(python3 -c 'import json,sys;print(json.load(open("/tmp/gh-aw/deterministic/results.json"))["pr_number"])')
echo "PR_NUMBER=${PR}" >> "${GITHUB_ENV}"
post-steps:
- name: Verify agent produced an add_comment safe-output
if: always() && github.event.workflow_run.conclusion == 'success'
@@ -80,10 +98,11 @@ The deterministic stage uploaded its results to the runner at
The JSON has this shape:
- `pr_number` — the PR being checked. The `add_comment` safe-output is
already targeted at this PR (the workflow extracted `pr_number` from
the artifact and wired it into the safe-output config), so **you do
not need to set `item_number` yourself** — just emit `add_comment`
with the rendered body.
already targeted at this PR (a pre-job extracts `pr_number` from the
artifact and the workflow wires it into the safe-output config via
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
set `item_number` yourself** — just emit `add_comment` with the
rendered body.
- `needs_agent``true` iff any package's check needs resolution.
- `packages[]` — one entry per changed package. Each entry has:
- `name`, `old_version` (`null` for a newly added package; otherwise the
@@ -161,9 +180,10 @@ Verify that the package's source repository is publicly reachable.
- Any other inconclusive result → ⚠️ with a one-line description.
If `repo_public` resolves to ❌ for a package, **also** mark that
package's `release_pipeline` cell/detail as `` (em dash) and explain
`Skipped because the source repository is not publicly accessible.` —
because the release pipeline cannot be inspected without a public repo.
package's `release_pipeline` and `async_blocking` cells/details as ``
(em dash) and explain `Skipped because the source repository is not
publicly accessible.` — neither check can be performed without a public
repo.
### Check kind: `pr_link`
@@ -239,6 +259,111 @@ host from `package.repo_url`, then apply the corresponding checklist.
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
inspected; hosting provider is not GitHub or GitLab.`
### Check kind: `async_blocking`
Verify whether the dependency performs blocking I/O inside async code
paths. Home Assistant runs on a single asyncio event loop, so a library
that exposes an `async` surface must not call blocking APIs from inside
its `async def` functions — that stalls the whole loop. A purely sync
library is fine: Home Assistant integrations are expected to wrap such
calls in an executor.
**Two modes — pick by inspecting `package.old_version`:**
- `old_version` is `null` → **new package**: review the *entire current
source tree*. Nothing about this dependency has been vetted before.
- `old_version` is a string → **version bump**: review only the *diff
between `old_version` and `new_version`*. The previous version was
already accepted, so blocking calls that were present in
`old_version` are not regressions; report only what `new_version`
introduces.
#### Step 1 — Decide whether the library exposes an async surface
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
(other hosts) on `package.repo_url`. Always inspect the tag /
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
- Locate the top-level package directory (usually named after the
import name, often equal or close to `package.name`).
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
example in the README).
- Grep the package source for `async def`. A handful of `async def`
entries in the public modules is enough to treat the library as
having an async surface.
If the library is **sync-only** (no `async def` in its public modules
and no async framework dependency) → ✅
`Sync-only library; Home Assistant integrations must wrap calls in an
executor.` *This verdict is the same in both modes.*
#### Step 2a — Mode: new package (`old_version` is `null`)
Inspect **every `async def` in the public modules** for blocking
patterns. Walk transitively into helpers the async functions call.
#### Step 2b — Mode: version bump (`old_version` is a string)
Fetch the diff between the two tags and review **only changed lines**:
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
the `github` MCP tool, or
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
via `web-fetch`. Try the common tag formats in order until one
resolves: `v{version}`, `{version}`, `release-{version}`.
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
- Other hosts: use the project's equivalent compare URL via
`web-fetch`.
If neither tag format resolves on the host, fall back to a full review
(Step 2a) and mention in the detail that the diff was unavailable.
When reviewing the diff, only flag blocking patterns that appear in
**added lines** *inside or reachable from* an `async def`. A blocking
call that existed in `old_version` and is unchanged is not a regression
for this bump.
#### Step 3 — Blocking patterns to look for
In both modes, the patterns to flag inside `async def` bodies are:
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
`AsyncClient`), `pycurl`.
- `time.sleep(` (must be `await asyncio.sleep(`).
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
blocking `select.select`.
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
non-trivial sizes (small one-shot reads during import are
acceptable; reads/writes on the request path are not — prefer
`aiofiles` / executor).
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
`pymongo` (sync client), `redis.Redis` (sync client).
- `subprocess.run` / `subprocess.call` / `os.system` (must be
`asyncio.create_subprocess_*`).
A call that is clearly dispatched to an executor
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
does NOT count as blocking.
#### Step 4 — Verdict
- ✅ — no offending blocking pattern in the surface being reviewed
(whole tree for a new package, added lines for a bump). For a bump,
phrase the detail as `No new blocking calls introduced in
{old_version} → {new_version}.`.
- ⚠️ — blocking calls exist only in sync helpers that the async API
does not call, or only on a clearly non-hot path (e.g. one-shot
setup before the event loop is running). Cite at least one
`<file>:<line>` and explain why it is not on the hot path.
- ❌ — a blocking call is reachable from an `async def` that is part
of the public API on the request / polling path (for a bump: the
call was introduced or moved onto the hot path by this version).
Cite the offending `<file>:<line>` as a clickable link on the repo
host so the contributor can jump to it.
## Notes
- Be constructive and helpful. Reference the inspected workflow / CI
+3 -3
View File
@@ -132,7 +132,7 @@
"problemMatcher": []
},
{
"label": "Install all Requirements",
"label": "Install all production Requirements",
"type": "shell",
"command": "uv pip install -r requirements_all.txt",
"group": {
@@ -146,9 +146,9 @@
"problemMatcher": []
},
{
"label": "Install all Test Requirements",
"label": "Install all (test & production) Requirements",
"type": "shell",
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
"command": "uv pip install -r requirements_all.txt -r requirements_test.txt",
"group": {
"kind": "build",
"isDefault": true
Generated
+4 -2
View File
@@ -466,6 +466,8 @@ CLAUDE.md @home-assistant/core
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
/tests/components/electric_kiwi/ @mikey0000
/homeassistant/components/electrolux/ @electrolux-oss
/tests/components/electrolux/ @electrolux-oss
/homeassistant/components/elevenlabs/ @sorgfresser
/tests/components/elevenlabs/ @sorgfresser
/homeassistant/components/elgato/ @frenck
@@ -1538,8 +1540,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/saj/ @fredericvl
/homeassistant/components/samsung_infrared/ @lmaertin
/tests/components/samsung_infrared/ @lmaertin
/homeassistant/components/samsungtv/ @chemelli74 @epenet
/tests/components/samsungtv/ @chemelli74 @epenet
/homeassistant/components/samsungtv/ @chemelli74
/tests/components/samsungtv/ @chemelli74
/homeassistant/components/sanix/ @tomaszsluszniak
/tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.17"]
"requirements": ["py-aosmith==1.0.18"]
}
@@ -1,9 +1,11 @@
"""Config flow to configure the Arcam FMJ component."""
import socket
from typing import Any
from urllib.parse import urlparse
from arcam.fmj.client import Client, ConnectionFailed
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn
import voluptuous as vol
@@ -29,26 +31,19 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(uuid)
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
async def _async_check_and_create(self, host: str, port: int) -> ConfigFlowResult:
async def _async_try_connect(self, host: str, port: int) -> None:
"""Verify the device is reachable."""
client = Client(host, port)
try:
await client.start()
except ConnectionFailed:
return self.async_abort(reason="cannot_connect")
finally:
await client.stop()
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({host})",
data={CONF_HOST: host, CONF_PORT: port},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
uuid = await get_uniqueid_from_host(
async_get_clientsession(self.hass), user_input[CONF_HOST]
@@ -58,18 +53,36 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
user_input[CONF_HOST], user_input[CONF_PORT], uuid
)
return await self._async_check_and_create(
user_input[CONF_HOST], user_input[CONF_PORT]
)
try:
await self._async_try_connect(
user_input[CONF_HOST], user_input[CONF_PORT]
)
except socket.gaierror:
errors["base"] = "invalid_host"
except TimeoutError:
errors["base"] = "timeout_connect"
except ConnectionRefusedError:
errors["base"] = "connection_refused"
except ConnectionFailed, OSError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
data={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
},
)
fields = {
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
schema = vol.Schema(fields)
if user_input is not None:
schema = self.add_suggested_values_to_schema(schema, user_input)
return self.async_show_form(
step_id="user", data_schema=vol.Schema(fields), errors=errors
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
@@ -79,7 +92,10 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
self.context["title_placeholders"] = placeholders
if user_input is not None:
return await self._async_check_and_create(self.host, self.port)
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({self.host})",
data={CONF_HOST: self.host, CONF_PORT: self.port},
)
return self.async_show_form(
step_id="confirm", description_placeholders=placeholders
@@ -97,6 +113,11 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
await self._async_set_unique_id_and_update(host, port, uuid)
try:
await self._async_try_connect(host, port)
except ConnectionFailed, OSError:
return self.async_abort(reason="cannot_connect")
self.host = host
self.port = DEFAULT_PORT
self.port = port
return await self.async_step_confirm()
@@ -5,6 +5,12 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"connection_refused": "Host refused connection",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
},
"flow_title": "{host}",
"step": {
"confirm": {
@@ -185,7 +185,6 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
except BSBLANError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
"An error occurred while updating the BSBLAN device",
translation_domain=DOMAIN,
translation_key="set_data_error",
) from err
@@ -45,6 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: CalDavConfigEntry) -> bo
# on some other unexpected server response.
_LOGGER.warning("Unexpected CalDAV server response: %s", err)
return False
except requests.Timeout as err:
raise ConfigEntryNotReady("Timeout connecting to CalDAV server") from err
except requests.ConnectionError as err:
raise ConfigEntryNotReady("Connection error from CalDAV server") from err
except DAVError as err:
+8 -2
View File
@@ -38,6 +38,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import CalDavConfigEntry
from .api import async_get_calendars
from .const import TIMEOUT
from .coordinator import CalDavUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -91,7 +92,12 @@ async def async_setup_platform(
days = config[CONF_DAYS]
client = caldav.DAVClient(
url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL]
url,
None,
username,
password,
ssl_verify_cert=config[CONF_VERIFY_SSL],
timeout=TIMEOUT,
)
calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT)
@@ -231,7 +237,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
await self.hass.async_add_executor_job(
partial(self.coordinator.calendar.add_event, **item_data),
)
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
@callback
+5 -5
View File
@@ -138,7 +138,7 @@ class WebDavTodoListEntity(TodoListEntity):
)
# refreshing async otherwise it would take too much time
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
async def async_update_todo_item(self, item: TodoItem) -> None:
@@ -150,7 +150,7 @@ class WebDavTodoListEntity(TodoListEntity):
)
except NotFoundError as err:
raise HomeAssistantError(f"Could not find To-do item {uid}") from err
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
vtodo = todo.icalendar_component # type: ignore[attr-defined]
vtodo["SUMMARY"] = item.summary or ""
@@ -174,7 +174,7 @@ class WebDavTodoListEntity(TodoListEntity):
)
# refreshing async otherwise it would take too much time
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
async def async_delete_todo_items(self, uids: list[str]) -> None:
@@ -188,14 +188,14 @@ class WebDavTodoListEntity(TodoListEntity):
items = await asyncio.gather(*tasks)
except NotFoundError as err:
raise HomeAssistantError("Could not find To-do item") from err
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
# Run serially as some CalDAV servers do not support concurrent modifications
for item in items:
try:
await self.hass.async_add_executor_job(item.delete)
except (requests.ConnectionError, DAVError) as err:
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
raise HomeAssistantError(f"CalDAV delete error: {err}") from err
# refreshing async otherwise it would take too much time
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
@@ -31,6 +31,7 @@ class EkeyEvent(EventEntity):
_attr_device_class = EventDeviceClass.BUTTON
_attr_event_types = ["event happened"]
_attr_has_entity_name = True
def __init__(
self,
@@ -0,0 +1,220 @@
"""The Electrolux integration."""
from asyncio import CancelledError
from collections.abc import Awaitable, Callable
import logging
from electrolux_group_developer_sdk.auth.token_manager import TokenManager
from electrolux_group_developer_sdk.client.appliance_client import ApplianceClient
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
BadCredentialsException,
)
from electrolux_group_developer_sdk.client.client_exception import (
ApplianceClientException,
)
from electrolux_group_developer_sdk.client.failed_connection_exception import (
FailedConnectionException,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import CONF_REFRESH_TOKEN, DOMAIN, NEW_APPLIANCE_SIGNAL, USER_AGENT
from .coordinator import (
ElectroluxConfigEntry,
ElectroluxData,
ElectroluxDataUpdateCoordinator,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
PLATFORMS = [
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: ElectroluxConfigEntry) -> bool:
"""Set up Electrolux integration entry."""
token_manager = create_token_manager(hass, entry)
client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
try:
await client.test_connection()
except BadCredentialsException as e:
raise ConfigEntryAuthFailed("Bad credentials detected.") from e
except FailedConnectionException as e:
raise ConfigEntryNotReady("Connection with client failed.") from e
try:
appliances = await fetch_appliance_data(client)
except ApplianceClientException as e:
raise ConfigEntryNotReady from e
coordinators: dict[str, ElectroluxDataUpdateCoordinator] = {}
on_livestream_opening_callback_list: list[Callable[[], Awaitable[None]]] = []
async def check_for_new_devices_callback() -> None:
"""Trigger _check_for_new_devices asynchronously."""
await _check_for_new_devices(
hass, entry, client, on_livestream_opening_callback_list
)
on_livestream_opening_callback_list.append(check_for_new_devices_callback)
for appliance in appliances:
appliance_id = appliance.appliance.applianceId
coordinator = ElectroluxDataUpdateCoordinator(
hass, entry, client=client, appliance_id=appliance_id
)
await coordinator.async_config_entry_first_refresh()
# Subscribe this coordinator to its appliance events
coordinator.add_client_listener()
coordinators[appliance_id] = coordinator
# Device state is refreshed whenever the SSE connection opens.
on_livestream_opening_callback_list.append(coordinator.async_refresh)
sse_task = entry.async_create_background_task(
hass,
client.start_event_stream(on_livestream_opening_callback_list),
"electrolux event listener",
)
entry.runtime_data = ElectroluxData(
client=client,
appliances=appliances,
coordinators=coordinators,
sse_task=sse_task,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ElectroluxConfigEntry) -> bool:
"""Unload a config entry."""
# Remove SSE listeners
coordinators = entry.runtime_data.coordinators
for coordinator in coordinators.values():
coordinator.remove_client_listeners()
# Cancel SSE task
sse_task = entry.runtime_data.sse_task
sse_task.cancel()
try:
await sse_task
except CancelledError:
_LOGGER.info("SSE stream cancelled for entry %s", entry.entry_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def create_token_manager(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
) -> TokenManager:
"""Create a token manager for the Electrolux integration."""
def save_tokens(new_access: str, new_refresh: str, new_api_key: str) -> None:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_API_KEY: new_api_key,
CONF_ACCESS_TOKEN: new_access,
CONF_REFRESH_TOKEN: new_refresh,
},
)
api_key = entry.data.get(CONF_API_KEY)
refresh_token = entry.data.get(CONF_REFRESH_TOKEN)
access_token = entry.data.get(CONF_ACCESS_TOKEN)
if access_token and refresh_token and api_key:
return TokenManager(access_token, refresh_token, api_key, save_tokens)
raise ConfigEntryAuthFailed("Missing access token, refresh token or API key")
async def _check_for_new_devices(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
client: ApplianceClient,
on_livestream_opening_callback_list: list[Callable[[], Awaitable[None]]],
) -> None:
"""Fetch appliances from API and trigger discovery for any new ones."""
_LOGGER.info("Checking for new devices")
coordinators = entry.runtime_data.coordinators
appliances = await fetch_appliance_data(client)
entry.runtime_data.appliances = appliances
existing_ids = set(coordinators.keys())
for appliance in appliances:
appliance_id = appliance.appliance.applianceId
# Detect NEW appliances
if appliance_id not in existing_ids:
# Create coordinator for appliance
coordinator = ElectroluxDataUpdateCoordinator(
hass, entry, client=client, appliance_id=appliance_id
)
await coordinator.async_refresh()
coordinator.add_client_listener()
coordinators[appliance_id] = coordinator
on_livestream_opening_callback_list.append(coordinator.async_refresh)
# Notify all platforms
async_dispatcher_send(
hass, f"{NEW_APPLIANCE_SIGNAL}_{entry.entry_id}", appliance
)
# Detect MISSING appliances
discovered_ids = {appliance.appliance.applianceId for appliance in appliances}
missing_ids = existing_ids - discovered_ids
device_registry = dr.async_get(hass)
for missing_id in missing_ids:
_LOGGER.warning("Appliance %s no longer found, removing", missing_id)
# Remove coordinator
coordinator = coordinators.pop(missing_id)
coordinator.remove_client_listeners()
on_livestream_opening_callback_list.remove(coordinator.async_refresh)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, missing_id)}
)
if device_entry:
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=entry.entry_id
)
async def fetch_appliance_data(client: ApplianceClient) -> list[ApplianceData]:
"""Helper method to retrieve all the appliances data from the Electrolux APIs."""
try:
appliances = await client.get_appliance_data()
except ApplianceClientException as e:
_LOGGER.warning("Failed to get appliances: %s", e)
raise
# Filter out appliances where details or state is None
return [
appliance
for appliance in appliances
if appliance.details is not None and appliance.state is not None
]
@@ -0,0 +1,99 @@
"""Config flow for Electrolux integration."""
from collections.abc import Mapping
import logging
from typing import Any
from electrolux_group_developer_sdk.auth.invalid_credentials_exception import (
InvalidCredentialsException,
)
from electrolux_group_developer_sdk.auth.token_manager import TokenManager
from electrolux_group_developer_sdk.client.appliance_client import ApplianceClient
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
BadCredentialsException,
)
from electrolux_group_developer_sdk.client.failed_connection_exception import (
FailedConnectionException,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY
from .const import CONF_REFRESH_TOKEN, DOMAIN, USER_AGENT
_LOGGER: logging.Logger = logging.getLogger(__name__)
class ElectroluxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for the Electrolux integration."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the config flow."""
errors: dict[str, str] = {}
if user_input is not None:
token_manager: TokenManager
email: str
try:
token_manager = await _authenticate_user(user_input)
client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
email = (await client.get_user_email()).email
except InvalidCredentialsException, BadCredentialsException:
errors["base"] = "invalid_auth"
except FailedConnectionException:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(token_manager.get_user_id())
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=f"Electrolux for {email}",
data=user_input,
)
return self._show_form(step_id="user", errors=errors)
def _show_form(self, step_id: str, errors: dict[str, str]) -> ConfigFlowResult:
return self.async_show_form(
step_id=step_id,
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_ACCESS_TOKEN): str,
vol.Required(CONF_REFRESH_TOKEN): str,
}
),
errors=errors,
description_placeholders={
"portal_link": "https://developer.electrolux.one/generateToken"
},
)
async def _authenticate_user(user_input: Mapping[str, Any]) -> TokenManager:
token_manager = TokenManager(
access_token=user_input[CONF_ACCESS_TOKEN],
refresh_token=user_input[CONF_REFRESH_TOKEN],
api_key=user_input[CONF_API_KEY],
)
token_manager.ensure_credentials()
appliance_client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
# Test a connection in the config flow
await appliance_client.test_connection()
return token_manager
@@ -0,0 +1,11 @@
"""Constants for Electrolux integration."""
from homeassistant.const import __version__ as HA_VERSION
DOMAIN = "electrolux"
CONF_REFRESH_TOKEN = "refresh_token"
NEW_APPLIANCE_SIGNAL = "electrolux_new_appliance"
USER_AGENT = f"HomeAssistant/{HA_VERSION}"
@@ -0,0 +1,96 @@
"""Electrolux coordinator class."""
from __future__ import annotations
from asyncio import Task
from dataclasses import dataclass
import logging
from electrolux_group_developer_sdk.client.appliance_client import (
ApplianceClient,
apply_sse_update,
)
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.client_exception import (
ApplianceClientException,
)
from electrolux_group_developer_sdk.client.dto.appliance_state import ApplianceState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER: logging.Logger = logging.getLogger(__name__)
@dataclass(kw_only=True, slots=True)
class ElectroluxData:
"""Electrolux data type."""
client: ApplianceClient
appliances: list[ApplianceData]
coordinators: dict[str, ElectroluxDataUpdateCoordinator]
sse_task: Task
type ElectroluxConfigEntry = ConfigEntry[ElectroluxData]
class ElectroluxDataUpdateCoordinator(DataUpdateCoordinator[ApplianceState]):
"""Class for fetching appliance data from the API."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ElectroluxConfigEntry,
client: ApplianceClient,
appliance_id: str,
) -> None:
"""Initialize."""
self.client = client
self._appliance_id = appliance_id
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_{config_entry.entry_id}_{appliance_id}",
update_interval=None,
always_update=False,
)
async def _async_update_data(self) -> ApplianceState:
"""Return the current appliance state (SSE keeps it updated)."""
try:
appliance_state = await self.client.get_appliance_state(self._appliance_id)
except ValueError as exception:
raise UpdateFailed(exception) from exception
except ApplianceClientException as exception:
raise UpdateFailed(exception) from exception
else:
return appliance_state
def add_client_listener(self) -> None:
"""Register an SSE listener to the appliance client for appliance state updates."""
self.client.add_listener(self._appliance_id, self.callback_handle_event)
def remove_client_listeners(self) -> None:
"""Remove all SSE listeners."""
self.client.remove_all_listeners_by_appliance_id(self._appliance_id)
def callback_handle_event(self, event: dict) -> None:
"""Handle an incoming SSE event. Event will look like: {"userId": "...", "applianceId": "...", "property": "timeToEnd", "value": 720}."""
current_state = self.data
if not current_state:
return
updated_state = apply_sse_update(
current_state,
event,
)
self.async_set_updated_data(updated_state)
@@ -0,0 +1,80 @@
"""Base entity for Electrolux integration."""
from abc import abstractmethod
import logging
from typing import TYPE_CHECKING
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ElectroluxDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
class ElectroluxBaseEntity[T: ApplianceData](
CoordinatorEntity[ElectroluxDataUpdateCoordinator]
):
"""Base class for Electrolux entities."""
_attr_has_entity_name = True
def __init__(
self,
appliance_data: T,
coordinator: ElectroluxDataUpdateCoordinator,
unique_id_suffix: str,
) -> None:
"""Initialize the base device."""
super().__init__(coordinator)
appliance_name = appliance_data.appliance.applianceName
appliance_id = appliance_data.appliance.applianceId
if TYPE_CHECKING:
assert appliance_data.details
assert appliance_data.state
appliance_info = appliance_data.details.applianceInfo
self._appliance_data = appliance_data
self._attr_unique_id = f"{appliance_id}_{unique_id_suffix}"
self._appliance_id = appliance_id
self._appliance_capabilities = appliance_data.details.capabilities
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance_id)},
name=appliance_name,
manufacturer=appliance_info.brand,
model=appliance_info.model,
serial_number=appliance_info.serialNumber,
)
async def async_added_to_hass(self) -> None:
"""When entity is added to HA."""
await super().async_added_to_hass()
self._handle_coordinator_update()
@abstractmethod
def _update_attr_state(self) -> bool:
"""Update entity-specific attributes. Returns True if any attributes were changed, otherwise False."""
@callback
def _handle_coordinator_update(self) -> None:
"""When the coordinator updates."""
appliance_state = self.coordinator.data
if not appliance_state:
_LOGGER.warning("Appliance %s not found in update", self._appliance_id)
return
# Update state
self._appliance_data.update_state(appliance_state)
state_changed = self._update_attr_state()
if state_changed:
self.async_write_ha_state()
@@ -0,0 +1,49 @@
"""Contains entity helper methods."""
from collections.abc import Callable
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEW_APPLIANCE_SIGNAL
from .coordinator import ElectroluxConfigEntry, ElectroluxDataUpdateCoordinator
from .entity import ElectroluxBaseEntity
async def async_setup_entities_helper(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
build_entities_fn: Callable[
[ApplianceData, dict[str, ElectroluxDataUpdateCoordinator]],
list[ElectroluxBaseEntity],
],
) -> None:
"""Provide async_setup_entry helper."""
appliances: list[ApplianceData] = entry.runtime_data.appliances
coordinators = entry.runtime_data.coordinators
entities: list[ElectroluxBaseEntity] = []
for appliance_data in appliances:
entities.extend(build_entities_fn(appliance_data, coordinators))
async_add_entities(entities)
# Listen for new/removed appliances
async def _new_appliance(appliance_data: ApplianceData):
new_entities = build_entities_fn(appliance_data, coordinators)
if new_entities:
async_add_entities(new_entities)
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{NEW_APPLIANCE_SIGNAL}_{entry.entry_id}", _new_appliance
)
)
@@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"appliance_state": {
"default": "mdi:information-outline"
},
"food_probe_state": {
"default": "mdi:thermometer-probe"
},
"food_probe_temperature": {
"default": "mdi:thermometer-probe"
},
"remote_control": {
"default": "mdi:remote"
}
}
}
}
@@ -0,0 +1,11 @@
{
"domain": "electrolux",
"name": "Electrolux",
"codeowners": ["@electrolux-oss"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/electrolux",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["electrolux-group-developer-sdk==0.5.0"]
}
@@ -0,0 +1,70 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions are implemented currently.
appropriate-polling:
status: exempt
comment: |
Polling is only performed on infrequent events (when the livestream of events is opened, in order to sync),
otherwise the integration works via push
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No actions are implemented currently.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,290 @@
"""Sensor entity for Electrolux Integration."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import cast
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.appliances.cr_appliance import CRAppliance
from electrolux_group_developer_sdk.client.appliances.ov_appliance import OVAppliance
from electrolux_group_developer_sdk.feature_constants import (
APPLIANCE_STATE,
DISPLAY_FOOD_PROBE_TEMPERATURE_C,
DISPLAY_FOOD_PROBE_TEMPERATURE_F,
DISPLAY_TEMPERATURE_C,
DISPLAY_TEMPERATURE_F,
FOOD_PROBE_STATE,
REMOTE_CONTROL,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from .coordinator import ElectroluxConfigEntry, ElectroluxDataUpdateCoordinator
from .entity import ElectroluxBaseEntity
from .entity_helper import async_setup_entities_helper
_LOGGER = logging.getLogger(__name__)
ELECTROLUX_TO_HA_TEMPERATURE_UNIT = {
"CELSIUS": UnitOfTemperature.CELSIUS,
"FAHRENHEIT": UnitOfTemperature.FAHRENHEIT,
}
@dataclass(frozen=True, kw_only=True)
class ElectroluxSensorDescription(SensorEntityDescription):
"""Custom sensor description for Electrolux sensors."""
value_fn: Callable[..., StateType]
exists_fn: Callable[[ApplianceData], bool] = lambda *args: True
feature_name: str | None = None
known_values: set[str] | None = None
OVEN_ELECTROLUX_SENSORS: tuple[ElectroluxSensorDescription, ...] = (
ElectroluxSensorDescription(
key="appliance_state",
translation_key="appliance_state",
value_fn=lambda appliance: appliance.get_current_appliance_state(),
device_class=SensorDeviceClass.ENUM,
feature_name=APPLIANCE_STATE,
exists_fn=lambda appliance: appliance.is_feature_supported(APPLIANCE_STATE),
known_values={
"alarm",
"delayed_start",
"end_of_cycle",
"idle",
"off",
"paused",
"ready_to_start",
"running",
},
),
ElectroluxSensorDescription(
key="food_probe_state",
translation_key="food_probe_state",
value_fn=lambda appliance: appliance.get_current_food_probe_insertion_state(),
device_class=SensorDeviceClass.ENUM,
feature_name=FOOD_PROBE_STATE,
exists_fn=lambda appliance: appliance.is_feature_supported(FOOD_PROBE_STATE),
known_values={
"inserted",
"not_inserted",
},
),
ElectroluxSensorDescription(
key="remote_control",
translation_key="remote_control",
value_fn=lambda appliance: appliance.get_current_remote_control(),
device_class=SensorDeviceClass.ENUM,
feature_name=REMOTE_CONTROL,
exists_fn=lambda appliance: appliance.is_feature_supported(REMOTE_CONTROL),
known_values={
"disabled",
"enabled",
"not_safety_relevant_enabled",
"temporary_locked",
},
),
)
OVEN_TEMPERATURE_ELECTROLUX_SENSORS: tuple[ElectroluxSensorDescription, ...] = (
ElectroluxSensorDescription(
key="food_probe_temperature",
translation_key="food_probe_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda appliance, temp_unit=None: (
appliance.get_current_display_food_probe_temperature_f()
if temp_unit == UnitOfTemperature.FAHRENHEIT
else appliance.get_current_display_food_probe_temperature_c()
),
exists_fn=lambda appliance: appliance.is_feature_supported(
[DISPLAY_FOOD_PROBE_TEMPERATURE_F, DISPLAY_FOOD_PROBE_TEMPERATURE_C]
),
),
ElectroluxSensorDescription(
key="display_temperature",
translation_key="display_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda appliance, temp_unit=None: (
appliance.get_current_display_temperature_f()
if temp_unit == UnitOfTemperature.FAHRENHEIT
else appliance.get_current_display_temperature_c()
),
exists_fn=lambda appliance: appliance.is_feature_supported(
[DISPLAY_TEMPERATURE_C, DISPLAY_TEMPERATURE_F]
),
),
)
def build_entities_for_appliance(
appliance_data: ApplianceData,
coordinators: dict[str, ElectroluxDataUpdateCoordinator],
) -> list[ElectroluxBaseEntity]:
"""Return all entities for a single appliance."""
appliance = appliance_data.appliance
coordinator = coordinators[appliance.applianceId]
entities: list[ElectroluxBaseEntity] = []
if isinstance(appliance_data, OVAppliance):
entities.extend(
ElectroluxSensor(appliance_data, coordinator, description)
for description in OVEN_ELECTROLUX_SENSORS
if description.exists_fn(appliance_data)
)
entities.extend(
ElectroluxTemperatureSensor(appliance_data, coordinator, description)
for description in OVEN_TEMPERATURE_ELECTROLUX_SENSORS
if description.exists_fn(appliance_data)
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set sensor for Electrolux Integration."""
await async_setup_entities_helper(
hass, entry, async_add_entities, build_entities_for_appliance
)
class ElectroluxSensor(ElectroluxBaseEntity[ApplianceData], SensorEntity):
"""Representation of a generic sensor for Electrolux appliances."""
entity_description: ElectroluxSensorDescription
def __init__(
self,
appliance_data: ApplianceData,
coordinator: ElectroluxDataUpdateCoordinator,
description: ElectroluxSensorDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(appliance_data, coordinator, description.key)
if (
description.feature_name is not None
and description.known_values is not None
):
options = appliance_data.get_feature_state_string_options(
description.feature_name
)
snake_case_options = [
snake_case_option
for option in options
if (snake_case_option := _convert_to_snake_case(option))
in description.known_values
]
if len(snake_case_options) > 0:
self._attr_options = snake_case_options
self.entity_description = description
def _update_attr_state(self) -> bool:
new_value = self._get_value()
if isinstance(new_value, str):
new_value = _convert_to_snake_case(new_value)
if self.entity_description.known_values:
new_value = _map_to_known_value(
self.entity_description.known_values,
self.entity_description.key,
new_value,
)
if self._attr_native_value != new_value:
self._attr_native_value = new_value
return True
return False
def _get_value(self) -> StateType:
return self.entity_description.value_fn(self._appliance_data)
class ElectroluxTemperatureSensor(ElectroluxSensor):
"""Representation of a temperature sensor for Electrolux appliances."""
def __init__(
self,
appliance_data: ApplianceData,
coordinator: ElectroluxDataUpdateCoordinator,
description: ElectroluxSensorDescription,
) -> None:
"""Initialize the sensor."""
self._appliance = cast(OVAppliance | CRAppliance, appliance_data)
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
super().__init__(appliance_data, coordinator, description)
def _get_value(self) -> StateType:
temp_unit = self._get_temperature_unit()
temp_value: float | None = cast(
float | None,
self.entity_description.value_fn(self._appliance_data, temp_unit=temp_unit),
)
if temp_value is None:
return None
return TemperatureConverter.convert(
temp_value, temp_unit, UnitOfTemperature.CELSIUS
)
def _get_temperature_unit(self) -> UnitOfTemperature:
temp_unit = self._appliance.get_current_temperature_unit()
if temp_unit is not None:
temp_unit = temp_unit.upper()
return ELECTROLUX_TO_HA_TEMPERATURE_UNIT.get(
temp_unit, UnitOfTemperature.CELSIUS
)
def _convert_to_snake_case(x: str) -> str:
"""Converts a string to snake case."""
lower_case = x.lower()
return "".join([_convert_char_to_snake_case(char) for char in lower_case])
def _convert_char_to_snake_case(char: str) -> str:
if char.isspace():
return "_"
return char
def _map_to_known_value(
known_values: set[str], entity_key: str, value: str
) -> str | None:
"""Return provided value if it is known, otherwise log warn message and return None."""
if value not in known_values:
_LOGGER.warning(
"An unknown value %s was reported for a sensor of the Electrolux integration. "
"Please report it for the integration, and include the following information: "
'entity key="%s", reported value="%s"',
value,
entity_key,
value,
)
return None
return value
@@ -0,0 +1,66 @@
{
"config": {
"abort": {
"already_configured": "This Electrolux account is already configured."
},
"error": {
"cannot_connect": "Unable to connect to the Electrolux API. Please check credentials and try again.",
"invalid_auth": "Authentication failed. Please check your credentials."
},
"step": {
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"refresh_token": "Refresh token"
},
"data_description": {
"access_token": "The access token from Electrolux Group for Developer.",
"api_key": "Your Electrolux Group for Developer API key.",
"refresh_token": "The refresh token used to renew your access token."
},
"description": "Please go to the [developer portal]({portal_link}) to generate new access and refresh tokens, then paste them below.",
"title": "Configure your Electrolux Group account"
}
}
},
"entity": {
"sensor": {
"appliance_state": {
"name": "Appliance state",
"state": {
"alarm": "Alarm",
"delayed_start": "Delayed start",
"end_of_cycle": "Cycle ended",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"paused": "[%key:common::state::paused%]",
"ready_to_start": "Ready to start",
"running": "Running"
}
},
"display_temperature": {
"name": "Current temperature"
},
"food_probe_state": {
"name": "Food probe state",
"state": {
"inserted": "Inserted",
"not_inserted": "Not inserted"
}
},
"food_probe_temperature": {
"name": "Food probe temperature"
},
"remote_control": {
"name": "Remote control",
"state": {
"disabled": "[%key:common::state::disabled%]",
"enabled": "[%key:common::state::enabled%]",
"not_safety_relevant_enabled": "Not safety relevant enabled",
"temporary_locked": "Temporarily locked"
}
}
}
}
}
+2 -2
View File
@@ -509,14 +509,12 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription(
icon="mdi:battery",
scope=FitbitScope.DEVICE,
entity_category=EntityCategory.DIAGNOSTIC,
has_entity_name=True,
)
FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription(
key="devices/battery_level",
translation_key="battery_level",
scope=FitbitScope.DEVICE,
entity_category=EntityCategory.DIAGNOSTIC,
has_entity_name=True,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
)
@@ -654,6 +652,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
entity_description: FitbitSensorEntityDescription
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(
self,
@@ -713,6 +712,7 @@ class FitbitBatteryLevelSensor(
"""Implementation of a Fitbit battery level sensor."""
entity_description: FitbitSensorEntityDescription
_attr_has_entity_name = True
_attr_attribution = ATTRIBUTION
def __init__(
+2
View File
@@ -438,6 +438,7 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity):
self._attr_is_on = turn_on
# pylint: disable-next=home-assistant-missing-has-entity-name
class FritzBoxPortSwitch(FritzBoxBaseSwitch):
"""Defines a FRITZ!Box Tools PortForward switch."""
@@ -604,6 +605,7 @@ class FritzBoxProfileSwitch(FritzBoxBaseCoordinatorSwitch):
self.async_write_ha_state()
# pylint: disable-next=home-assistant-missing-has-entity-name
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
"""Defines a FRITZ!Box Tools Wifi switch."""
@@ -806,10 +806,10 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
data["daysOfMonth"] = [start_date.day]
data["weeksOfMonth"] = []
if interval := call.data.get(ATTR_INTERVAL):
if (interval := call.data.get(ATTR_INTERVAL)) is not None:
data["everyX"] = interval
if streak := call.data.get(ATTR_STREAK):
if (streak := call.data.get(ATTR_STREAK)) is not None:
data["streak"] = streak
try:
@@ -266,14 +266,12 @@ class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
value=BSH_POWER_ON,
)
except HomeConnectError as err:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="power_on",
translation_placeholders={
**get_dict_from_home_connect_error(err),
"appliance_name": self.appliance.info.name,
"value": BSH_POWER_ON,
},
) from err
@@ -180,27 +180,24 @@ async def async_setup_entry(
class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP cloud connection sensor."""
_attr_translation_key = "cloud_connection"
def __init__(self, hap: HomematicipHAP) -> None:
"""Initialize the cloud connection sensor."""
super().__init__(hap, hap.home, feature_id="cloud_connection")
@property
def name(self) -> str:
"""Return the name cloud connection entity."""
name = "Cloud Connection"
# Add a prefix to the name if the homematic ip home has a name.
return name if not self._home.name else f"{self._home.name} {name}"
@property
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
# Adds a sensor to the existing HAP device
# Merges into the existing HAP device registered in __init__.py.
# Name must match __init__.py logic for has_entity_name to work.
label = self._home.label or ""
return DeviceInfo(
identifiers={
# Serial numbers of Homematic IP device
(DOMAIN, self._home.id)
}
},
name=label,
)
@property
@@ -579,6 +576,7 @@ class HomematicipPluggableMainsFailureSurveillanceSensor(
class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP security zone sensor group."""
_attr_has_entity_name = False
_attr_device_class = BinarySensorDeviceClass.SAFETY
def __init__(
@@ -74,6 +74,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
basically enabled in the hmip app.
"""
_attr_has_entity_name = False
_attr_supported_features = (
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
)
@@ -320,6 +320,7 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
"""Representation of the HomematicIP cover shutter group."""
_attr_has_entity_name = False
_attr_device_class = CoverDeviceClass.SHUTTER
def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None:
@@ -74,6 +74,7 @@ GROUP_ATTRIBUTES = {
class HomematicipGenericEntity(Entity):
"""Representation of the HomematicIP generic entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
@@ -112,6 +113,14 @@ class HomematicipGenericEntity(Entity):
# Marker showing that the HmIP device hase been removed.
self.hmip_device_removed = False
# Compute entity name based on has_entity_name mode.
if not self._attr_has_entity_name:
# Legacy mode (groups, special entities): compose the full name
# including device/group label and home prefix.
self._attr_name = self._compute_legacy_name()
else:
self._setup_entity_name()
@property
def device_info(self) -> DeviceInfo | None:
"""Return device specific attributes."""
@@ -120,6 +129,14 @@ class HomematicipGenericEntity(Entity):
device_id = str(self._device.id)
home_id = str(self._device.homeId)
# Include the home name in the device name so that the
# previous "{home} {device}" naming is preserved after
# switching to has_entity_name=True.
device_name = self._device.label
home_name = getattr(self._home, "name", None)
if device_name and home_name:
device_name = f"{home_name} {device_name}"
return DeviceInfo(
identifiers={
# Serial numbers of Homematic IP device
@@ -127,7 +144,7 @@ class HomematicipGenericEntity(Entity):
},
manufacturer=self._device.oem,
model=self._device.modelType,
name=self._device.label,
name=device_name,
sw_version=self._device.firmwareVersion,
# Link to the homematic ip access point.
via_device=(DOMAIN, home_id),
@@ -200,38 +217,93 @@ class HomematicipGenericEntity(Entity):
self.async_remove(force_remove=True), eager_start=False
)
@property
def name(self) -> str:
"""Return the name of the generic entity."""
def _compute_legacy_name(self) -> str:
"""Compute the full legacy name for entities without has_entity_name.
name = ""
# Try to get a label from a channel.
functional_channels = getattr(self._device, "functionalChannels", None)
if functional_channels and self.functional_channel:
if self._is_multi_channel:
label = getattr(self.functional_channel, "label", None)
if label:
name = str(label)
elif len(functional_channels) > 1:
label = getattr(functional_channels[1], "label", None)
if label:
name = str(label)
# Use device label, if name is not defined by channel label.
if not name:
name = self._device.label or ""
if self._post:
name = f"{name} {self._post}"
elif self._is_multi_channel:
name = f"{name} Channel{self.get_channel_index()}"
# Add a prefix to the name if the homematic ip home has a name.
Used by group entities and other special cases where has_entity_name
is False. Includes device/group label, post suffix, and home prefix.
"""
name = self._device.label or ""
if self._post:
name = f"{name} {self._post}" if name else self._post
home_name = getattr(self._home, "name", None)
if name and home_name:
name = f"{home_name} {name}"
return name
def _setup_entity_name(self) -> None:
"""Set up entity naming for has_entity_name mode.
With has_entity_name=True, HA composes the full friendly name as
"{device_name} {entity_name}". This method sets the appropriate
naming attributes.
For multi-channel entities, channel labels provide _attr_name (dynamic).
For entities with _post, _attr_name is derived from the post suffix,
with the first letter capitalized for display consistency.
For primary entities, HA uses device_class as the name.
"""
# Multi-channel entities: use channel label as entity name.
if self._is_multi_channel and self.functional_channel:
label = getattr(self.functional_channel, "label", None)
if label:
label_str = str(label)
device_label = self._device.label or ""
# Strip device name prefix from channel label to avoid
# duplication when HA composes "{device_name} {entity_name}".
# E.g., device "Licht Flur" + channel "Licht Flur 5" -> "5".
if device_label and label_str.startswith(device_label):
stripped = label_str[len(device_label) :].strip()
if stripped:
self._attr_name = stripped
# Otherwise channel label equals device label (modulo
# whitespace); leave _attr_name unset so HA composes just
# the device name without duplicating it.
return
self._attr_name = label_str
return
# Fallback: use post suffix or generic channel name.
if self._post:
self._attr_name = self._post[0].upper() + self._post[1:]
else:
self._attr_name = f"Channel{self.get_channel_index()}"
return
# Entities with a post suffix: use it as the entity name,
# capitalizing the first letter for display consistency.
if self._post:
self._attr_name = self._post[0].upper() + self._post[1:]
return
# Non-multi-channel entities on devices with multiple channels:
# use the first functional channel's label as name context.
# This preserves names like "Treppe CH" for single-function entities
# on multi-channel devices (e.g., HmIP-BSL switch channel).
functional_channels = getattr(self._device, "functionalChannels", None)
if functional_channels and len(functional_channels) > 1:
ch1 = (
functional_channels.get(1)
if isinstance(functional_channels, dict)
else functional_channels[1]
)
label = getattr(ch1, "label", None) if ch1 else None
if label:
label_str = str(label)
device_label = self._device.label or ""
# Strip device name prefix to avoid duplication.
if device_label and label_str.startswith(device_label):
stripped = label_str[len(device_label) :].strip()
if stripped:
self._attr_name = stripped
# Otherwise channel label equals device label (modulo
# whitespace); leave _attr_name unset.
return
self._attr_name = label_str
return
# Primary entity on device: leave unset so HA derives name from
# device_class or translation_key.
@property
def available(self) -> bool:
"""Return if entity is available."""
@@ -82,7 +82,6 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
super().__init__(
hap,
device,
post=description.key,
channel=channel,
is_multi_channel=False,
feature_id="doorbell",
@@ -1070,9 +1070,7 @@ class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity):
description: HmipSmokeDetectorSensorDescription,
) -> None:
"""Initialize the smoke detector sensor."""
super().__init__(
hap, device, post=description.key, feature_id="smoke_detector_sensor"
)
super().__init__(hap, device, feature_id="smoke_detector_sensor")
self.entity_description = description
self._sensor_unique_id = f"{device.id}_{description.key}"
@@ -37,6 +37,11 @@
}
},
"entity": {
"binary_sensor": {
"cloud_connection": {
"name": "Cloud connection"
}
},
"light": {
"optical_signal_light": {
"state_attributes": {
@@ -142,6 +142,8 @@ class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):
class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity):
"""Representation of the HomematicIP switching group."""
_attr_has_entity_name = False
def __init__(self, hap: HomematicipHAP, device, post: str = "Group") -> None:
"""Initialize switching group."""
device.modelType = f"HmIP-{post}"
@@ -74,11 +74,6 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity):
"""Initialize the weather sensor."""
super().__init__(hap, device, feature_id="weather")
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._device.label
@property
def native_temperature(self) -> float:
"""Return the platform temperature."""
@@ -118,6 +113,7 @@ class HomematicipWeatherSensorPro(HomematicipWeatherSensor):
class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity):
"""Representation of the HomematicIP home weather."""
_attr_has_entity_name = False
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_attribution = "Powered by Homematic IP"
@@ -16,6 +16,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_COMMAND,
CONF_HOST,
CONF_ID,
CONF_NAME,
@@ -39,9 +40,6 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
# pylint: disable-next=home-assistant-duplicate-const
CONF_COMMAND = "command"
EVENT_BUTTON_PRESS = "homeworks_button_press"
EVENT_BUTTON_RELEASE = "homeworks_button_release"
@@ -73,8 +73,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) ->
except InvalidHeaterList as exc:
raise NoHeaters from exc
except InvalidGateway as exc:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed("Incorrect credentials") from exc
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="incorrect_credentials"
) from exc
except ClientResponseError as exc:
if exc.status == 404:
raise NotFound from exc
@@ -15,10 +15,12 @@ from incomfortclient import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator]
_LOGGER = logging.getLogger(__name__)
@@ -77,16 +79,20 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
try:
for heater in self.incomfort_data.heaters:
await heater.update()
except TimeoutError as exc:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed("Timeout error") from exc
except ClientResponseError as exc:
if exc.status == 401:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryError("Incorrect credentials") from exc
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(exc.message) from exc
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="incorrect_credentials"
) from exc
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_with_error_message",
translation_placeholders={"error": exc.message},
) from exc
except InvalidHeaterList as exc:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(exc.message) from exc
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_with_error_message",
translation_placeholders={"error": exc.message},
) from exc
return self.incomfort_data
@@ -131,6 +131,7 @@
}
},
"exceptions": {
"incorrect_credentials": { "message": "Incorrect credentials." },
"no_heaters": {
"message": "[%key:component::incomfort::config::error::no_heaters%]"
},
@@ -142,6 +143,9 @@
},
"unknown": {
"message": "[%key:component::incomfort::config::error::unknown%]"
},
"update_failed_with_error_message": {
"message": "Update failed, got {error}."
}
},
"options": {
@@ -72,7 +72,11 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
config_data = await self.api.get_config()
except (ClientError, OSError) as err:
raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
translation_placeholders={"error": str(err)},
) from err
# Cache device information
device_data = config_data.get("device", {})
@@ -87,7 +91,11 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
return await self.api.fetch_data(sensor_keys)
except (ClientError, OSError) as err:
raise UpdateFailed(f"Device update failed: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
"""Push/write data values to given key on the device."""
@@ -56,7 +56,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
@@ -373,6 +373,9 @@
}
},
"exceptions": {
"config_entry_not_ready": {
"message": "Device config retrieval failed: {error}"
},
"energy_mode_change_unavailable_outdoor_portable": {
"message": "Energy mode cannot be changed when the device is in outdoor/portable mode"
},
@@ -400,6 +403,9 @@
"soc_below_minimum": {
"message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)"
},
"update_failed": {
"message": "Device update failed: {error}"
},
"write_error": {
"message": "Cannot update value for {name}"
}
+2 -5
View File
@@ -556,9 +556,8 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(
CONF_KNX_ROUTE_BACK, default=_route_back
): selector.BooleanSelector(),
vol.Optional(CONF_KNX_LOCAL_IP): _IP_SELECTOR,
}
if self.show_advanced_options:
fields[vol.Optional(CONF_KNX_LOCAL_IP)] = _IP_SELECTOR
if not self._found_tunnels and not errors.get("base"):
errors["base"] = "no_tunnel_discovered"
@@ -890,10 +889,8 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
): selector.BooleanSelector(),
vol.Required(CONF_KNX_MCAST_GRP, default=_multicast_group): _IP_SELECTOR,
vol.Required(CONF_KNX_MCAST_PORT, default=_multicast_port): _PORT_SELECTOR,
vol.Optional(CONF_KNX_LOCAL_IP): _IP_SELECTOR,
}
if self.show_advanced_options:
# Optional with default doesn't work properly in flow UI
fields[vol.Optional(CONF_KNX_LOCAL_IP)] = _IP_SELECTOR
return self.async_show_form(
step_id="routing", data_schema=vol.Schema(fields), errors=errors
@@ -216,6 +216,8 @@ class LunatoneLineBroadcastLight(
_attr_assumed_state = True
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -22,10 +22,6 @@ _LOGGER = logging.getLogger(__name__)
ATTRIBUTION = "Information provided by MeteoAlarm"
# pylint: disable-next=home-assistant-duplicate-const
CONF_COUNTRY = "country"
# pylint: disable-next=home-assistant-duplicate-const
CONF_LANGUAGE = "language"
CONF_PROVINCE = "province"
DEFAULT_NAME = "meteoalarm"
@@ -10,8 +10,6 @@ DEFAULT_DETECTION_TIME: Final = 300
ATTR_MANUFACTURER: Final = "Mikrotik"
ATTR_SERIAL_NUMBER: Final = "serial-number"
ATTR_FIRMWARE: Final = "current-firmware"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL: Final = "model"
CONF_ARP_PING: Final = "arp_ping"
CONF_FORCE_DHCP: Final = "force_dhcp"
@@ -9,7 +9,13 @@ import librouteros
from librouteros.login import plain as login_plain, token as login_token
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.const import (
ATTR_MODEL,
CONF_HOST,
CONF_PASSWORD,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -17,7 +23,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
ARP,
ATTR_FIRMWARE,
ATTR_MODEL,
ATTR_SERIAL_NUMBER,
CAPSMAN,
CONF_ARP_PING,
+23 -21
View File
@@ -11,27 +11,29 @@ publish:
example: "The temperature is {{ states('sensor.temperature') }}"
selector:
template:
evaluate_payload:
default: false
selector:
boolean:
qos:
default: 0
selector:
select:
options:
- "0"
- "1"
- "2"
retain:
default: false
selector:
boolean:
message_expiry_interval:
selector:
duration:
enable_day: true
publish_options:
collapsed: true
fields:
evaluate_payload:
default: false
selector:
boolean:
qos:
default: "0"
selector:
select:
options:
- "0"
- "1"
- "2"
retain:
default: false
selector:
boolean:
message_expiry_interval:
selector:
duration:
enable_day: true
dump:
fields:
topic:
+6 -1
View File
@@ -1578,7 +1578,12 @@
"name": "Topic"
}
},
"name": "Publish"
"name": "Publish",
"sections": {
"publish_options": {
"name": "Publish options"
}
}
},
"reload": {
"description": "Reloads MQTT entities from the YAML-configuration.",
@@ -46,7 +46,6 @@ play_media:
- "add"
translation_key: enqueue
radio_mode:
advanced: true
selector:
boolean:
@@ -138,20 +137,21 @@ search:
example: "News of the world"
selector:
text:
limit:
advanced: true
example: 25
default: 5
selector:
number:
min: 1
max: 100
step: 1
library_only:
example: "true"
default: false
selector:
boolean:
search_options:
fields:
limit:
example: 25
default: 5
selector:
number:
min: 1
max: 100
step: 1
library_only:
example: "true"
default: false
selector:
boolean:
get_library:
fields:
@@ -183,24 +183,24 @@ get_library:
example: "We Are The Champions"
selector:
text:
limit:
advanced: true
example: 25
default: 25
selector:
number:
min: 1
max: 500
step: 1
offset:
advanced: true
example: 25
default: 0
selector:
number:
min: 1
max: 1000000
step: 1
pagination:
fields:
limit:
example: 25
default: 25
selector:
number:
min: 1
max: 500
step: 1
offset:
example: 25
default: 0
selector:
number:
min: 0
max: 1000000
step: 1
order_by:
example: "random"
selector:
@@ -360,7 +360,12 @@
"name": "Search"
}
},
"name": "Get library items"
"name": "Get library items",
"sections": {
"pagination": {
"name": "Pagination"
}
}
},
"get_queue": {
"description": "Retrieves the details of the currently active queue of a Music Assistant player.",
@@ -450,7 +455,12 @@
"name": "Search name"
}
},
"name": "Search Music Assistant"
"name": "Search Music Assistant",
"sections": {
"search_options": {
"name": "Search options"
}
}
},
"transfer_queue": {
"description": "Transfers a player's queue to another player.",
@@ -1 +0,0 @@
"""Virtual integration: National Grid US."""
@@ -1,6 +0,0 @@
{
"domain": "national_grid_us",
"name": "National Grid US",
"integration_type": "virtual",
"supported_by": "opower"
}
@@ -13,7 +13,7 @@ rules:
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
has-entity-name: todo
runtime-data: done
test-before-configure: done
test-before-setup: done
+1 -1
View File
@@ -23,7 +23,6 @@ _ATTRIBUTION = "Data provided by OMIE.es"
SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
key: SensorEntityDescription(
key=key,
has_entity_name=True,
translation_key=key,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
@@ -36,6 +35,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
class OMIEPriceSensor(CoordinatorEntity[OMIECoordinator], SensorEntity):
"""OMIE price sensor."""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_attribution = _ATTRIBUTION
@@ -11,7 +11,7 @@ upload_image:
media:
accept:
- image/*
advanced_options:
additional_fields:
collapsed: true
fields:
rotation:
@@ -151,8 +151,8 @@
},
"name": "Upload image",
"sections": {
"advanced_options": {
"name": "Advanced options"
"additional_fields": {
"name": "Additional options"
}
}
}
@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.20.3"],
"requirements": ["pyoverkiz==1.20.4"],
"zeroconf": [
{
"name": "gateway*",
@@ -15,8 +15,6 @@ ATTR_REMOTE = "remote"
ATTR_DEVICE_INFO = "device_info"
ATTR_FRIENDLY_NAME = "friendlyName"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MANUFACTURER = "manufacturer"
ATTR_MODEL_NUMBER = "modelNumber"
ATTR_UDN = "UDN"
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
from homeassistant.const import CONF_NAME
from homeassistant.const import ATTR_MANUFACTURER, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,7 +23,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PanasonicVieraConfigEntry
from .const import (
ATTR_DEVICE_INFO,
ATTR_MANUFACTURER,
ATTR_MODEL_NUMBER,
ATTR_UDN,
DEFAULT_MANUFACTURER,
@@ -4,7 +4,7 @@ from collections.abc import Iterable
from typing import Any
from homeassistant.components.remote import RemoteEntity
from homeassistant.const import CONF_NAME, STATE_ON
from homeassistant.const import ATTR_MANUFACTURER, CONF_NAME, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PanasonicVieraConfigEntry, Remote
from .const import (
ATTR_DEVICE_INFO,
ATTR_MANUFACTURER,
ATTR_MODEL_NUMBER,
ATTR_UDN,
DEFAULT_MANUFACTURER,
@@ -4,7 +4,7 @@ rules:
brands: done
dependency-transparency: done
common-modules: done
has-entity-name: done
has-entity-name: todo
action-setup:
status: done
comment: |
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.9"]
"requirements": ["renault-api==0.5.10"]
}
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/risco",
"iot_class": "local_push",
"loggers": ["pyrisco"],
"requirements": ["pyrisco==0.6.8"]
"requirements": ["pyrisco==0.7.0"]
}
@@ -1,7 +1,7 @@
{
"domain": "samsungtv",
"name": "Samsung Smart TV",
"codeowners": ["@chemelli74", "@epenet"],
"codeowners": ["@chemelli74"],
"config_flow": true,
"dependencies": ["ssdp"],
"dhcp": [
@@ -401,7 +401,6 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]):
"""Fetch data."""
if self.sleep_period:
# Sleeping device, no point polling it, just mark it unavailable
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error_sleeping_device",
@@ -671,7 +670,6 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
if self.sleep_period:
# Sleeping device, no point polling it, just mark it unavailable
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error_sleeping_device",
+43 -10
View File
@@ -2,6 +2,7 @@
import base64
import binascii
import contextlib
from dataclasses import dataclass
import datetime
import hashlib
@@ -38,6 +39,15 @@ from .utils import get_device_entry_gen
CONTENT_TYPE_AUDIO = "audio"
CONTENT_TYPE_RADIO = "radio"
ALLOWED_IMAGE_MIME_TYPES: Final = frozenset(
{
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
}
)
PARALLEL_UPDATES = 0
@@ -102,6 +112,9 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
_last_media_position: int | None = None
_last_media_position_updated_at: datetime.datetime | None = None
_cached_thumb: str | None = None
_cached_thumb_result: tuple[bytes, str] | None = None
def __init__(
self,
coordinator: ShellyRpcCoordinator,
@@ -215,9 +228,11 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
@property
def media_image_hash(self) -> str | None:
"""Hash value for media image."""
if (thumb := self._media_meta.get("thumb")) and thumb.startswith("data"):
return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16]
return super().media_image_hash
thumb = self._media_meta.get("thumb")
if not thumb or self._decode_image_data(thumb) is None:
return super().media_image_hash
return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16]
def _get_updated_media_position(self) -> int | None:
"""Return the current playback position and update its timestamp."""
@@ -235,15 +250,11 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
"""Fetch media image of current playing track."""
thumb = self._media_meta["thumb"]
try:
prefix, image_data = thumb.split(",", 1)
image = base64.b64decode(image_data, validate=True)
mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1]
except binascii.Error, ValueError:
thumb = self._media_meta.get("thumb")
if not thumb or (result := self._decode_image_data(thumb)) is None:
return await super().async_get_media_image()
return image, mime
return result
@rpc_call
async def async_media_play(self) -> None:
@@ -434,3 +445,25 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
translation_key="unsupported_media_type",
translation_placeholders={"media_type": str(media_type)},
)
def _decode_image_data(self, thumb: str) -> tuple[bytes, str] | None:
"""Return image_bytes and mime_type for a valid image data or None."""
if thumb == self._cached_thumb:
return self._cached_thumb_result
result: tuple[bytes, str] | None = None
if thumb.startswith("data"):
try:
prefix, image_data = thumb.split(",", 1)
mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1]
except IndexError, ValueError:
pass
else:
if mime in ALLOWED_IMAGE_MIME_TYPES:
with contextlib.suppress(binascii.Error):
result = base64.b64decode(image_data, validate=True), mime
self._cached_thumb = thumb
self._cached_thumb_result = result
return result
+1 -1
View File
@@ -673,7 +673,7 @@
"message": "An error occurred while reconnecting to {device}"
},
"update_error_sleeping_device": {
"message": "Sleeping device did not update within {period} seconds interval"
"message": "Sleeping device {device} did not update within {period} seconds interval"
}
},
"issues": {
+1 -5
View File
@@ -344,14 +344,10 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity):
translation_placeholders={"device": self.coordinator.name},
) from err
except RpcCallError as err:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="ota_update_rpc_error",
translation_placeholders={
"entity": self.entity_id,
"device": self.coordinator.name,
},
translation_placeholders={"device": self.coordinator.name},
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
@@ -69,18 +69,14 @@ class SMADataUpdateCoordinator(DataUpdateCoordinator[SMACoordinatorData]):
SmaConnectionException,
) as err:
await self.async_close_sma_session()
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except SmaAuthenticationException as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> SMACoordinatorData:
@@ -91,18 +87,14 @@ class SMADataUpdateCoordinator(DataUpdateCoordinator[SMACoordinatorData]):
SmaReadException,
SmaConnectionException,
) as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except SmaAuthenticationException as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
return SMACoordinatorData(
+2 -2
View File
@@ -69,10 +69,10 @@
},
"exceptions": {
"cannot_connect": {
"message": "Could not connect to SMA device - {error}"
"message": "Could not connect to SMA device"
},
"invalid_auth": {
"message": "Invalid authentication for SMA device - {error}"
"message": "Invalid authentication for SMA device"
}
},
"selector": {
@@ -25,6 +25,8 @@ async def async_setup_entry(
class SmartThingsScene(Scene):
"""Define a SmartThings scene."""
_attr_has_entity_name = True
def __init__(self, scene: STScene, client: SmartThings) -> None:
"""Init the scene class."""
self.client = client
@@ -394,7 +394,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.COMPLETION_TIME,
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
)
],
},
@@ -447,7 +447,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.COMPLETION_TIME,
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
)
],
},
@@ -565,7 +565,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.GAS_METER_TIME,
translation_key="gas_meter_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
)
],
Attribute.GAS_METER_VOLUME: [
@@ -726,7 +726,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.COMPLETION_TIME,
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
component_fn=lambda component: component == "cavity-01",
component_translation_key={
"cavity-01": "oven_completion_time_cavity_01",
@@ -1196,7 +1196,7 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.COMPLETION_TIME,
translation_key="completion_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
component_fn=lambda component: component == "sub",
component_translation_key={
"sub": "washer_sub_completion_time",
-2
View File
@@ -9,8 +9,6 @@ ATTR_HTML: Final = "html"
ATTR_SENDER_NAME: Final = "sender_name"
CONF_ENCRYPTION: Final = "encryption"
# pylint: disable-next=home-assistant-duplicate-const
CONF_DEBUG: Final = "debug"
CONF_SERVER: Final = "server"
CONF_SENDER_NAME: Final = "sender_name"
+1 -1
View File
@@ -24,6 +24,7 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.const import (
CONF_DEBUG,
CONF_PASSWORD,
CONF_PORT,
CONF_RECIPIENT,
@@ -44,7 +45,6 @@ from homeassistant.util.ssl import create_client_context
from .const import (
ATTR_HTML,
ATTR_IMAGES,
CONF_DEBUG,
CONF_ENCRYPTION,
CONF_SENDER_NAME,
CONF_SERVER,
+1
View File
@@ -195,6 +195,7 @@ class SonosFavoritesEntity(SensorEntity):
"""Representation of a Sonos favorites info entity."""
_attr_entity_registry_enabled_default = False
_attr_has_entity_name = True
_attr_name = "Sonos favorites"
_attr_translation_key = "favorites"
_attr_native_unit_of_measurement = "items"
+1 -2
View File
@@ -11,7 +11,7 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MAX_LENGTH_STATE_STATE
from homeassistant.const import ATTR_MODE, MAX_LENGTH_STATE_STATE
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -23,7 +23,6 @@ from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_MAX,
ATTR_MIN,
ATTR_MODE,
ATTR_PATTERN,
ATTR_VALUE,
DOMAIN,
-2
View File
@@ -4,8 +4,6 @@ DOMAIN = "text"
ATTR_MAX = "max"
ATTR_MIN = "min"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODE = "mode"
ATTR_PATTERN = "pattern"
ATTR_VALUE = "value"
@@ -38,6 +38,7 @@ async def async_setup_entry(
)
# pylint: disable-next=home-assistant-missing-has-entity-name
class OmadaClientScannerEntity(
CoordinatorEntity[OmadaClientsCoordinator], ScannerEntity
):
@@ -51,14 +51,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry)
try:
await client.authenticate()
except ApiAuthError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(
f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"host": entry.data[CONF_HOST]},
) from err
except ApiConnectionError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryNotReady(
f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}"
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"host": entry.data[CONF_HOST]},
) from err
coordinator = UnifiAccessCoordinator(hass, entry, client)
@@ -197,17 +197,25 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
self.client.get_emergency_status(),
)
except ApiAuthError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="update_failed_auth",
) from err
except ApiConnectionError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(f"Error connecting to API: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_connection",
) from err
except ApiError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(f"Error communicating with API: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_api",
) from err
except TimeoutError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed("Timeout communicating with UniFi Access API") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_timeout",
) from err
previous_lock_rules = self.data.door_lock_rules.copy() if self.data else {}
door_lock_rules: dict[str, DoorLockRuleStatus] = {}
@@ -133,6 +133,12 @@
}
},
"exceptions": {
"auth_failed": {
"message": "Authentication failed for UniFi Access at {host}."
},
"cannot_connect": {
"message": "Unable to connect to UniFi Access at {host}."
},
"emergency_failed": {
"message": "Failed to set emergency status."
},
@@ -150,6 +156,18 @@
},
"unlock_failed": {
"message": "Failed to unlock the door."
},
"update_failed_api": {
"message": "Error communicating with the UniFi Access API."
},
"update_failed_auth": {
"message": "Authentication failed while updating data."
},
"update_failed_connection": {
"message": "Error connecting to the UniFi Access API."
},
"update_failed_timeout": {
"message": "Timeout communicating with the UniFi Access API."
}
},
"selector": {
@@ -84,8 +84,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
except NotAuthorized as err:
data_service.auth_retries += 1
if data_service.auth_retries > AUTH_RETRIES:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="entry_auth_failed",
) from err
raise ConfigEntryNotReady from err
except (TimeoutError, ClientError, ServerDisconnectedError) as err:
raise ConfigEntryNotReady from err
@@ -105,12 +105,14 @@ def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]:
@callback
def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn:
msg = f"Unexpected identifier: {identifier}"
exc = BrowseError(
translation_domain=DOMAIN,
translation_key="unexpected_identifier",
translation_placeholders={"identifier": identifier},
)
if err is None:
# pylint: disable-next=home-assistant-exception-not-translated
raise BrowseError(msg)
# pylint: disable-next=home-assistant-exception-not-translated
raise BrowseError(msg) from err
raise exc
raise exc from err
@callback
@@ -379,8 +381,10 @@ class ProtectMediaSource(MediaSource):
_bad_identifier(f"{data.api.bootstrap.nvr.id}:{subtype}:{event_id}", err)
if event.start is None or event.end is None:
# pylint: disable-next=home-assistant-exception-not-translated
raise BrowseError("Event is still ongoing")
raise BrowseError(
translation_domain=DOMAIN,
translation_key="event_ongoing",
)
return await self._build_event(data, event, thumbnail_only)
@@ -790,8 +794,11 @@ class ProtectMediaSource(MediaSource):
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
if camera is None:
# pylint: disable-next=home-assistant-exception-not-translated
raise BrowseError(f"Unknown Camera ID: {camera_id}")
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_camera_id",
translation_placeholders={"camera_id": camera_id},
)
name = camera.name or camera.market_name or camera.type
is_doorbell = camera.feature_flags.is_doorbell
has_smart = camera.feature_flags.has_smart_detect
@@ -679,6 +679,12 @@
"device_not_found": {
"message": "No device found for device id: {device_id}"
},
"entry_auth_failed": {
"message": "Authentication failed, please reauthenticate"
},
"event_ongoing": {
"message": "Event is still ongoing"
},
"global_alarm_manager": {
"message": "The alarm manager on this UniFi Protect NVR is set to Global mode and cannot be controlled locally."
},
@@ -717,6 +723,12 @@
},
"stream_error": {
"message": "Error playing audio, check the logs for more details"
},
"unexpected_identifier": {
"message": "Unexpected identifier: {identifier}"
},
"unknown_camera_id": {
"message": "Unknown camera ID: {camera_id}"
}
},
"issues": {
@@ -30,6 +30,9 @@
}
},
"exceptions": {
"cannot_access_or_create_backup_path": {
"message": "Cannot access or create backup path"
},
"cannot_connect": {
"message": "Cannot connect to WebDAV server"
},
+1 -5
View File
@@ -48,14 +48,10 @@ class LgWebOSNotificationService(BaseNotificationService):
icon_path = data.get(ATTR_ICON) if data else None
if not client.tv_state.is_on:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="notify_device_off",
translation_placeholders={
"name": str(self._entry.title),
"func": __name__,
},
translation_placeholders={"name": str(self._entry.title)},
)
try:
await client.send_message(message, icon_path=icon_path)
+1 -1
View File
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["wled==0.22.0"],
"requirements": ["wled==0.23.0"],
"zeroconf": ["_wled._tcp.local."]
}
+124 -50
View File
@@ -1,5 +1,6 @@
"""Support for Wyoming intent recognition services."""
import asyncio
import logging
from typing import Any, Literal
@@ -7,7 +8,7 @@ from wyoming.asr import Transcript
from wyoming.client import AsyncTcpClient
from wyoming.handle import Handled, NotHandled
from wyoming.info import HandleProgram, IntentProgram
from wyoming.intent import Intent, NotRecognized
from wyoming.intent import Intent, IntentsStart, IntentsStop, NotRecognized
from homeassistant.components import conversation
from homeassistant.const import MATCH_ALL
@@ -86,6 +87,10 @@ class WyomingConversationEntity(
model_languages.update(handle_model.languages)
self._attr_name = self._handle_service.name
if self._handle_service.supports_home_control:
self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL
)
self._supported_languages = list(model_languages)
self._attr_unique_id = f"{config_entry.entry_id}-conversation"
@@ -165,62 +170,27 @@ class WyomingConversationEntity(
intent_response: intent.IntentResponse,
) -> intent.IntentResponse:
"""Process a sentence into an intent response."""
has_intents_list = False
intents: list[Intent] = []
while True:
event = await client.read_event()
if event is None:
raise WyomingError("Connection lost")
if IntentsStart.is_type(event.type):
# Multiple intents may be present
has_intents_list = True
continue
if Intent.is_type(event.type):
# Success
recognized_intent = Intent.from_event(event)
_LOGGER.debug("Recognized intent: %s", recognized_intent)
intent_type = recognized_intent.name
intent_slots = {
e.name: {"value": e.value} for e in recognized_intent.entities
}
# Add to trace and chat log
conversation.async_conversation_trace_append(
conversation.ConversationTraceEventType.TOOL_CALL,
{
"intent_name": intent_type,
"slots": intent_slots,
},
)
tool_input = llm.ToolInput(
tool_name=intent_type,
tool_args=intent_slots,
external=True,
)
chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id=user_input.agent_id,
content=None,
tool_calls=[tool_input],
)
)
intent_response = await intent.async_handle(
self.hass,
DOMAIN,
intent_type,
intent_slots,
text_input=user_input.text,
language=user_input.language,
satellite_id=user_input.satellite_id,
device_id=user_input.device_id,
)
if (not intent_response.speech) and recognized_intent.text:
response_text = recognized_intent.text
if template.is_template_string(response_text):
# Render text as a template
response_text = self._render_speech_template(
response_text, intent_response, intent_slots
)
intent_response.async_set_speech(response_text)
intents.append(Intent.from_event(event))
if not has_intents_list:
# Only one intent, no need to wait
break
if IntentsStop.is_type(event.type):
# End of intents list
break
if NotRecognized.is_type(event.type):
@@ -230,6 +200,9 @@ class WyomingConversationEntity(
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
not_recognized.text or "",
)
# Don't process any intents if one was not recognized
intents.clear()
break
if Handled.is_type(event.type):
@@ -247,6 +220,107 @@ class WyomingConversationEntity(
)
break
if not intents:
return intent_response
# Process recognized intents with a task group.
# If any intent fails to be handled, the rest are cancelled.
intent_responses: list[intent.IntentResponse] = []
try:
async with asyncio.TaskGroup() as task_group:
intent_tasks: list[tuple[str, dict, str | None, asyncio.Task]] = []
for recognized_intent in intents:
_LOGGER.debug("Handling intent: %s", recognized_intent)
intent_type = recognized_intent.name
intent_slots = {
e.name: {"value": e.value} for e in recognized_intent.entities
}
# Add to trace
conversation.async_conversation_trace_append(
conversation.ConversationTraceEventType.TOOL_CALL,
{
"intent_name": intent_type,
"slots": intent_slots,
},
)
intent_tasks.append(
(
intent_type,
intent_slots,
recognized_intent.text,
task_group.create_task(
intent.async_handle(
self.hass,
DOMAIN,
intent_type,
intent_slots,
text_input=user_input.text,
language=user_input.language,
satellite_id=user_input.satellite_id,
device_id=user_input.device_id,
)
),
)
)
except* intent.IntentError as err_group:
# Bubble up first exception only.
# There's nothing the caller can do with multiple intent errors.
raise err_group.exceptions[0] from err_group
# Gather intent handling results
tool_calls: list[llm.ToolInput] = []
for intent_type, intent_slots, intent_text, intent_task in intent_tasks:
intent_task_response = await intent_task
intent_responses.append(intent_task_response)
# For the chat log
tool_calls.append(
llm.ToolInput(
tool_name=intent_type,
tool_args=intent_slots,
external=True,
)
)
# Process speech
if (not intent_task_response.speech) and intent_text:
if template.is_template_string(intent_text):
# Render text as a template
intent_text = self._render_speech_template(
intent_text, intent_task_response, intent_slots
)
intent_task_response.async_set_speech(intent_text)
# Add all tool calls to the chat log
chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id=user_input.agent_id,
content=None,
tool_calls=tool_calls,
)
)
# Must be the case because an exception would have been thrown otherwise
assert intent_responses
# Use the properties of the first intent (response_type, etc.) and
# combine the speech results.
intent_response = intent_responses[0]
speech_texts: list[str] = [
speech
for current_response in intent_responses
if (speech := current_response.speech.get("plain", {}).get("speech"))
]
if speech_texts:
# Combine response with newlines because punctuation would be
# language-dependent.
intent_response.async_set_speech("\n".join(speech_texts))
return intent_response
def _render_speech_template(
@@ -14,10 +14,9 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ZeversolarConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data: ZeverSolarData = config_entry.runtime_data.data
payload: dict[str, Any] = {
return {
"wifi_enabled": data.wifi_enabled,
"serial_or_registry_id": data.serial_or_registry_id,
"registry_key": data.registry_key,
@@ -33,8 +32,6 @@ async def async_get_config_entry_diagnostics(
"meter_status": data.meter_status.value,
}
return payload
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: ZeversolarConfigEntry, device: DeviceEntry
@@ -42,15 +39,13 @@ async def async_get_device_diagnostics(
"""Return diagnostics for a device entry."""
coordinator = entry.runtime_data
updateInterval = (
None
if coordinator.update_interval is None
else coordinator.update_interval.total_seconds()
)
return {
"name": coordinator.name,
"always_update": coordinator.always_update,
"last_update_success": coordinator.last_update_success,
"update_interval": updateInterval,
"update_interval": (
None
if coordinator.update_interval is None
else coordinator.update_interval.total_seconds()
),
}
+1
View File
@@ -91,6 +91,7 @@ CONF_COMMAND_ON: Final = "command_on"
CONF_COMMAND_OPEN: Final = "command_open"
CONF_COMMAND_STATE: Final = "command_state"
CONF_COMMAND_STOP: Final = "command_stop"
CONF_COMMENT: Final = "comment"
CONF_CONDITION: Final = "condition"
CONF_CONDITIONS: Final = "conditions"
CONF_CONTINUE_ON_ERROR: Final = "continue_on_error"
+1
View File
@@ -192,6 +192,7 @@ FLOWS = {
"ekeybionyx",
"electrasmart",
"electric_kiwi",
"electrolux",
"elevenlabs",
"elgato",
"elkm1",
+6 -5
View File
@@ -1708,6 +1708,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"electrolux": {
"name": "Electrolux",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"elevenlabs": {
"name": "ElevenLabs",
"integration_type": "service",
@@ -4613,11 +4619,6 @@
"config_flow": true,
"iot_class": "local_push"
},
"national_grid_us": {
"name": "National Grid US",
"integration_type": "virtual",
"supported_by": "opower"
},
"neato": {
"name": "Neato Botvac",
"integration_type": "hub",
@@ -38,6 +38,7 @@ from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_BELOW,
CONF_CHOOSE,
CONF_COMMENT,
CONF_CONDITION,
CONF_CONDITIONS,
CONF_CONTINUE_ON_ERROR,
@@ -1458,6 +1459,7 @@ SCRIPT_SCHEMA = vol.All(ensure_list, [script_action])
SCRIPT_ACTION_BASE_SCHEMA: VolDictType = {
vol.Optional(CONF_ALIAS): string,
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
vol.Optional(CONF_CONTINUE_ON_ERROR): boolean,
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
}
@@ -1525,6 +1527,7 @@ NUMERIC_STATE_THRESHOLD_SCHEMA = vol.Any(
CONDITION_BASE_SCHEMA: VolDictType = {
vol.Optional(CONF_ALIAS): string,
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
}
@@ -1859,6 +1862,7 @@ TRIGGER_BASE_SCHEMA = vol.Schema(
vol.Optional(CONF_ID): str,
vol.Optional(CONF_VARIABLES): SCRIPT_VARIABLES_SCHEMA,
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
}
)
@@ -0,0 +1,360 @@
"""Checker for missing ``_attr_has_entity_name`` on entity classes.
**Quality-scale-gated** (Bronze): only fires for integrations whose
``quality_scale.yaml`` marks ``has-entity-name`` as ``done``.
Every entity class instantiated by a platform must *statically guarantee*
``has_entity_name=True`` for every instance. Patterns that set the flag
conditionally or per-instance work today but don't enforce the rule, so
they're rejected; integrations that need them can use
``# pylint: disable=home-assistant-missing-has-entity-name`` after
verifying that all instantiations end up True.
Accepted paths (any one, in the class or any ancestor):
1. Class body: ``_attr_has_entity_name = True`` (or
``_attr_has_entity_name: bool = True``).
2. Top-level statement of a method body: ``self._attr_has_entity_name = True``.
Must be the literal value ``True`` and must NOT be nested inside
``if``/``for``/``try``/etc. that ensures it runs on every instance.
3. Class-level annotation ``entity_description: SomeDescription`` whose
description class (or an ancestor) sets ``has_entity_name = True``
as a class-level default.
Mixin/base classes that are subclassed by another class in the same
module are exempted on the assumption that the subclass is the runtime
entity.
Known limitations
-----------------
The rule is a high-signal heuristic, not a soundness proof. Two
intentional scope choices to be aware of:
- **Per-instance override.** When an EntityDescription subclass sets a
class-level ``has_entity_name = True`` default, a specific instance
can still be constructed with ``has_entity_name=False``. We accept
based on the class default and do not scan call sites.
- **Computed or dynamic assignment.** ``@property has_entity_name``,
``setattr(self, "_attr_has_entity_name", ...)``, and any factory or
metaprogrammed path are not detected static analysis can't follow
them.
``# pylint: disable=home-assistant-missing-has-entity-name`` on the
offending class declaration is the recommended escape hatch.
https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/has-entity-name
"""
from collections.abc import Iterator
import astroid
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant.const import ENTITY_COMPONENTS, QualityScaleRule
from pylint_home_assistant.helpers.module_info import get_module_platform
from pylint_home_assistant.helpers.quality_scale import quality_scale_rule_is_done
_ENTITY_QNAME = "homeassistant.helpers.entity.Entity"
_ENTITY_DESCRIPTION_QNAME = "homeassistant.helpers.entity.EntityDescription"
_ATTR_NAME = "_attr_has_entity_name"
_DESCRIPTION_ATTR = "entity_description"
_DESCRIPTION_FIELD = "has_entity_name"
def _safe_ancestors(class_node: nodes.ClassDef) -> list[nodes.ClassDef]:
"""Return ``class_node.ancestors()`` swallowing inference errors."""
try:
return list(class_node.ancestors())
except astroid.exceptions.InferenceError:
return []
def _subscript_base_classes(class_node: nodes.ClassDef) -> Iterator[nodes.ClassDef]:
"""Yield ClassDefs from subscript bases (e.g. ``class B(Base[T])``).
astroid's ``ancestors()`` drops Subscript bases such as
``VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier]`` (PEP-695 generic
syntax), so this method recovers them by inferring the subscript's
value.
"""
for base in class_node.bases:
if not isinstance(base, nodes.Subscript):
continue
try:
inferred = list(base.value.infer())
except astroid.exceptions.InferenceError:
continue
for inferred_node in inferred:
if isinstance(inferred_node, nodes.ClassDef):
yield inferred_node
def _extended_ancestors(class_node: nodes.ClassDef) -> Iterator[nodes.ClassDef]:
"""Yield all ancestors including transitive subscript-based ones."""
seen: set[str] = set()
stack: list[nodes.ClassDef] = [class_node]
while stack:
current = stack.pop()
for ancestor in _safe_ancestors(current):
qname = ancestor.qname()
if qname in seen:
continue
seen.add(qname)
yield ancestor
stack.append(ancestor)
for subscript_base in _subscript_base_classes(current):
qname = subscript_base.qname()
if qname in seen:
continue
seen.add(qname)
yield subscript_base
stack.append(subscript_base)
def _inherits_from_entity(class_node: nodes.ClassDef) -> bool:
"""Return True if class inherits from homeassistant.helpers.entity.Entity."""
return any(a.qname() == _ENTITY_QNAME for a in _extended_ancestors(class_node))
def _class_body_sets_attr_true(class_node: nodes.ClassDef, attr_name: str) -> bool:
"""Return True if class body assigns ``attr_name = True``.
Limitation: only literal ``Const(True)`` values are recognised.
``dataclasses.field(default=True)`` and other ``Call``-shaped defaults
are not detected even though they evaluate to True at runtime.
"""
for item in class_node.body:
if (
isinstance(item, nodes.Assign)
and any(
isinstance(target, nodes.AssignName) and target.name == attr_name
for target in item.targets
)
and isinstance(item.value, nodes.Const)
and item.value.value is True
):
return True
if (
isinstance(item, nodes.AnnAssign)
and isinstance(item.target, nodes.AssignName)
and item.target.name == attr_name
and isinstance(item.value, nodes.Const)
and item.value.value is True
):
return True
return False
def _method_unconditionally_sets_attr_true(class_node: nodes.ClassDef) -> bool:
"""Return True if a method unconditionally sets self._attr_has_entity_name=True.
Accepts both ``self._attr_has_entity_name = True`` (``Assign``) and
``self._attr_has_entity_name: bool = True`` (``AnnAssign``). The
assignment must:
- have the literal value ``True``,
- be a direct statement of the method body (not nested in
``if``/``for``/``try``/etc.), and
- be preceded only by flow-safe statements (other assignments,
``super()`` and other expression calls, ``pass``). Any statement
that could divert control flow (``return``, ``raise``, ``if``,
loops, ``try``, ``assert``, etc.) before the assignment ends the
scan, since after that point the assignment is no longer
guaranteed to run.
"""
for method in class_node.body:
if not isinstance(method, nodes.FunctionDef | nodes.AsyncFunctionDef):
continue
for stmt in method.body:
if isinstance(stmt, nodes.Assign):
targets = stmt.targets
value = stmt.value
elif isinstance(stmt, nodes.AnnAssign):
targets = [stmt.target]
value = stmt.value
elif isinstance(stmt, nodes.Expr | nodes.AugAssign | nodes.Pass):
# Flow-safe; keep scanning past it.
continue
else:
# Control-flow statement (Return/Raise/If/For/While/Try/
# Assert/etc.); the target assignment after this point
# is no longer guaranteed to run.
break
if not (isinstance(value, nodes.Const) and value.value is True):
continue
for target in targets:
if (
isinstance(target, nodes.AssignAttr)
and target.attrname == _ATTR_NAME
and isinstance(target.expr, nodes.Name)
and target.expr.name == "self"
):
return True
return False
def _is_entity_description(class_node: nodes.ClassDef) -> bool:
"""Return True if class is or inherits from EntityDescription."""
if class_node.qname() == _ENTITY_DESCRIPTION_QNAME:
return True
return any(
ancestor.qname() == _ENTITY_DESCRIPTION_QNAME
for ancestor in _safe_ancestors(class_node)
)
def _description_sets_has_entity_name(description_class: nodes.ClassDef) -> bool:
"""Return True if the description class or any ancestor sets has_entity_name = True."""
if _class_body_sets_attr_true(description_class, _DESCRIPTION_FIELD):
return True
return any(
_class_body_sets_attr_true(ancestor, _DESCRIPTION_FIELD)
for ancestor in _safe_ancestors(description_class)
)
def _entity_description_annotation_satisfies(class_node: nodes.ClassDef) -> bool:
"""Return True if a typed entity_description supplies has_entity_name=True.
The class must declare an ``entity_description: SomeDescription``
annotation, and that description class (or an ancestor) must set
``has_entity_name = True`` as a class-level default. This detects
the ``EntityDescription.has_entity_name`` fallback path used by
integrations like unifi, where the entity itself sets neither
``_attr_has_entity_name`` nor ``self._attr_has_entity_name`` but
the typed description class supplies a True default.
"""
for item in class_node.body:
if not isinstance(item, nodes.AnnAssign):
continue
if not isinstance(item.target, nodes.AssignName):
continue
if item.target.name != _DESCRIPTION_ATTR:
continue
annotation = item.annotation
if isinstance(annotation, nodes.Subscript):
annotation = annotation.value
try:
inferred = list(annotation.infer())
except astroid.exceptions.InferenceError:
continue
for inferred_node in inferred:
if (
isinstance(inferred_node, nodes.ClassDef)
and _is_entity_description(inferred_node)
and _description_sets_has_entity_name(inferred_node)
):
return True
return False
def _class_satisfies_rule(class_node: nodes.ClassDef) -> bool:
"""Return True if this single class satisfies the rule on its own.
Checks the three runtime resolution paths against the class body
only ancestors are checked by the caller.
"""
return (
_class_body_sets_attr_true(class_node, _ATTR_NAME)
or _method_unconditionally_sets_attr_true(class_node)
or _entity_description_annotation_satisfies(class_node)
)
def _has_entity_name_handled(class_node: nodes.ClassDef) -> bool:
"""Return True if the rule is satisfied by the class or any ancestor."""
if _class_satisfies_rule(class_node):
return True
return any(
_class_satisfies_rule(ancestor) for ancestor in _extended_ancestors(class_node)
)
def _collect_same_module_ancestor_qnames(module: nodes.Module) -> set[str]:
"""Return qnames of every class used as an ancestor by any class in *module*.
Used to exempt mixin/abstract bases from the rule. Three limitations
fall out of the "same-module" scoping:
1. A class that is BOTH a same-module base AND directly instantiated
(e.g. both the base and a subclass passed to async_add_entities)
is exempted by this filter.
2. A base defined here but only subclassed from a *different* module
(e.g. base in sensor.py, subclasses in binary_sensor.py) is NOT
exempted and would be flagged as if it were a runtime entity.
3. An abstract-by-convention class with no same-module subclass at
all is flagged. This rule should be disable for those classes
after verifying the class is never instantiated.
"""
qnames: set[str] = set()
for class_node in module.nodes_of_class(nodes.ClassDef):
for ancestor in _extended_ancestors(class_node):
qnames.add(ancestor.qname())
return qnames
class HasEntityNameChecker(BaseChecker):
"""Checker for missing ``_attr_has_entity_name`` on entity classes."""
name = "home_assistant_has_entity_name"
priority = -1
msgs = {
"W7416": (
(
"Entity class `%s` should set `_attr_has_entity_name = True` "
"(https://developers.home-assistant.io/docs/core/"
"integration-quality-scale/rules/has-entity-name)"
),
"home-assistant-missing-has-entity-name",
(
"Used when an entity class defined in a platform module does "
"not statically guarantee has_entity_name=True via a class-"
"level _attr_has_entity_name = True, an unconditional "
"self._attr_has_entity_name = True at the top of a method, or "
"an entity_description annotation whose description class "
"sets has_entity_name = True as a default. Conditional and "
"per-instance patterns are intentionally rejected."
),
),
}
options = ()
_check_module: bool
_subclassed_qnames: set[str]
def visit_module(self, node: nodes.Module) -> None:
"""Cache per-module gating result."""
platform = get_module_platform(node.name)
self._check_module = (
platform is not None
and platform in ENTITY_COMPONENTS
and quality_scale_rule_is_done(node, QualityScaleRule.HAS_ENTITY_NAME)
)
self._subclassed_qnames = (
_collect_same_module_ancestor_qnames(node) if self._check_module else set()
)
def visit_classdef(self, node: nodes.ClassDef) -> None:
"""Flag entity classes missing _attr_has_entity_name."""
if not self._check_module:
return
if not _inherits_from_entity(node):
return
if _has_entity_name_handled(node):
return
# Skip mixin / abstract bases: another class in the same module
# inherits from this one, so this class is not the runtime entity.
if node.qname() in self._subclassed_qnames:
return
self.add_message(
"home-assistant-missing-has-entity-name",
node=node,
args=(node.name,),
)
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(HasEntityNameChecker(linter))
+8 -5
View File
@@ -891,6 +891,9 @@ ekey-bionyxpy==1.0.1
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
# homeassistant.components.electrolux
electrolux-group-developer-sdk==0.5.0
# homeassistant.components.elevenlabs
elevenlabs==2.3.0
@@ -1890,7 +1893,7 @@ pushover_complete==1.2.0
pvo==3.0.0
# homeassistant.components.aosmith
py-aosmith==1.0.17
py-aosmith==1.0.18
# homeassistant.components.canary
py-canary==0.5.4
@@ -2413,7 +2416,7 @@ pyotgw==2.2.3
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.20.3
pyoverkiz==1.20.4
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2479,7 +2482,7 @@ pyrecswitch==1.0.2
pyrepetierng==0.1.0
# homeassistant.components.risco
pyrisco==0.6.8
pyrisco==0.7.0
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.7
@@ -2865,7 +2868,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.9
renault-api==0.5.10
# homeassistant.components.renson
renson-endura-delta==1.7.2
@@ -3356,7 +3359,7 @@ wiim==0.1.2
wirelesstagpy==0.8.1
# homeassistant.components.wled
wled==0.22.0
wled==0.23.0
# homeassistant.components.wolflink
wolf-comm==0.0.48
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -9,7 +9,8 @@ cd "$(realpath "$(dirname "$0")/..")"
echo "Installing development dependencies..."
uv pip install \
-e . \
-r requirements_test_all.txt \
-r requirements_all.txt \
-r requirements_test.txt \
colorlog \
--upgrade \
--config-settings editable_mode=compat

Some files were not shown because too many files have changed in this diff Show More