mirror of
https://github.com/home-assistant/core.git
synced 2026-05-21 16:25:18 +02:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff43e12449 | |||
| 654408cc76 | |||
| 1f814faad8 | |||
| 6e00eecfcd | |||
| 8c8620c511 | |||
| cca8825ca5 | |||
| 92fbcc29a5 | |||
| 1c28833f39 | |||
| cfdef77222 | |||
| 49720475da | |||
| 7967b84cc6 | |||
| c715557813 | |||
| 79e5330782 | |||
| 5210ca64b1 | |||
| 65283e3d77 | |||
| 427cb9f8db | |||
| a09e042d42 | |||
| 072e9b51a2 | |||
| b96342c4f3 | |||
| 56eae8c808 | |||
| 9fbdf86104 | |||
| 8ff5da59c4 | |||
| 298f4f8ed0 | |||
| 6fdc0bb90b | |||
| 94c3ad2cb2 | |||
| d83d44648c | |||
| 279b614b7c | |||
| 244dfe014a | |||
| 6b379e50cf | |||
| 1368cd15da | |||
| 8c8cc3acb9 | |||
| b0634bea35 | |||
| 5ae31cad6f | |||
| b45aaaa177 | |||
| 6560496440 | |||
| 489dda8efb | |||
| 30c942d139 | |||
| c735e47e23 | |||
| 3856405c72 | |||
| 323479ca44 | |||
| c8bfe56975 | |||
| ab214b64f2 | |||
| fea673d93a | |||
| 5405151112 | |||
| b3c210ef24 | |||
| 5f5d74cfbd | |||
| c188fdcc8b | |||
| a3b43fc19b | |||
| 894a68acb6 | |||
| 30bc3fc412 | |||
| 7dfef5c82a | |||
| b75cd0f6a7 | |||
| 7859aba432 |
@@ -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
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+3
-3
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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__(
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+1
@@ -192,6 +192,7 @@ FLOWS = {
|
||||
"ekeybionyx",
|
||||
"electrasmart",
|
||||
"electric_kiwi",
|
||||
"electrolux",
|
||||
"elevenlabs",
|
||||
"elgato",
|
||||
"elkm1",
|
||||
|
||||
@@ -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))
|
||||
Generated
+8
-5
@@ -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
@@ -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
Reference in New Issue
Block a user