mirror of
https://github.com/home-assistant/core.git
synced 2026-05-21 16:25:18 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 696f0d15f2 |
@@ -19,6 +19,7 @@ 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
|
||||
|
||||
+23
-55
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1ad29b8fb97f5df4466be54051779a3188f094d7efb041a8ed55211eab33c5f5","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,9 +65,7 @@ run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs:
|
||||
- extract_pr_number
|
||||
- pre_activation
|
||||
needs: pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
@@ -191,20 +189,20 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_bb296919e461941b_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_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -233,12 +231,12 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -316,9 +314,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
agent:
|
||||
needs:
|
||||
- activation
|
||||
- extract_pr_number
|
||||
needs: activation
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -389,6 +385,11 @@ 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:
|
||||
@@ -453,15 +454,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_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
|
||||
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
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
{
|
||||
"description_suffixes": {
|
||||
"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."
|
||||
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ env.PR_NUMBER }}. Supports reply_to_id for discussion threading."
|
||||
},
|
||||
"repo_params": {},
|
||||
"dynamic_tools": []
|
||||
@@ -647,7 +648,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_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -691,7 +692,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -938,7 +939,6 @@ jobs:
|
||||
- activation
|
||||
- agent
|
||||
- detection
|
||||
- extract_pr_number
|
||||
- safe_outputs
|
||||
if: >
|
||||
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
|
||||
@@ -1283,37 +1283,6 @@ 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:
|
||||
@@ -1352,7 +1321,6 @@ jobs:
|
||||
- activation
|
||||
- agent
|
||||
- detection
|
||||
- extract_pr_number
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
@@ -1425,7 +1393,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\":\"${{ 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_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\":{}}"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -19,30 +19,7 @@ tools:
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
max: 1
|
||||
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}"
|
||||
target: "${{ env.PR_NUMBER }}"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
@@ -55,6 +32,11 @@ 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'
|
||||
@@ -98,11 +80,10 @@ 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 (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.
|
||||
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.
|
||||
- `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
|
||||
@@ -180,10 +161,9 @@ 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` 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.
|
||||
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.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
@@ -259,111 +239,6 @@ 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 production Requirements",
|
||||
"label": "Install all Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements_all.txt",
|
||||
"group": {
|
||||
@@ -146,9 +146,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all (test & production) Requirements",
|
||||
"label": "Install all Test Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements_all.txt -r requirements_test.txt",
|
||||
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
|
||||
Generated
+4
-4
@@ -1413,8 +1413,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/pushover/ @engrbm87
|
||||
/homeassistant/components/pvoutput/ @frenck
|
||||
/tests/components/pvoutput/ @frenck
|
||||
/homeassistant/components/pvpc_hourly_pricing/ @azogue @chiro79
|
||||
/tests/components/pvpc_hourly_pricing/ @azogue @chiro79
|
||||
/homeassistant/components/pvpc_hourly_pricing/ @azogue
|
||||
/tests/components/pvpc_hourly_pricing/ @azogue
|
||||
/homeassistant/components/pyload/ @tr4nt0r
|
||||
/tests/components/pyload/ @tr4nt0r
|
||||
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
|
||||
@@ -1538,8 +1538,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/saj/ @fredericvl
|
||||
/homeassistant/components/samsung_infrared/ @lmaertin
|
||||
/tests/components/samsung_infrared/ @lmaertin
|
||||
/homeassistant/components/samsungtv/ @chemelli74
|
||||
/tests/components/samsungtv/ @chemelli74
|
||||
/homeassistant/components/samsungtv/ @chemelli74 @epenet
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
/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.18"]
|
||||
"requirements": ["py-aosmith==1.0.17"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Config flow to configure the Arcam FMJ component."""
|
||||
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.client import Client, ConnectionFailed
|
||||
from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -31,19 +29,26 @@ 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_try_connect(self, host: str, port: int) -> None:
|
||||
"""Verify the device is reachable."""
|
||||
async def _async_check_and_create(self, host: str, port: int) -> ConfigFlowResult:
|
||||
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]
|
||||
@@ -53,36 +58,18 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], uuid
|
||||
)
|
||||
|
||||
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],
|
||||
},
|
||||
)
|
||||
return await self._async_check_and_create(
|
||||
user_input[CONF_HOST], 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=schema, errors=errors)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -92,10 +79,7 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.context["title_placeholders"] = placeholders
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({self.host})",
|
||||
data={CONF_HOST: self.host, CONF_PORT: self.port},
|
||||
)
|
||||
return await self._async_check_and_create(self.host, self.port)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm", description_placeholders=placeholders
|
||||
@@ -113,11 +97,6 @@ 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 = port
|
||||
self.port = DEFAULT_PORT
|
||||
return await self.async_step_confirm()
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
"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,6 +185,7 @@ 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,8 +45,6 @@ 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,7 +38,6 @@ 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__)
|
||||
@@ -92,12 +91,7 @@ async def async_setup_platform(
|
||||
days = config[CONF_DAYS]
|
||||
|
||||
client = caldav.DAVClient(
|
||||
url,
|
||||
None,
|
||||
username,
|
||||
password,
|
||||
ssl_verify_cert=config[CONF_VERIFY_SSL],
|
||||
timeout=TIMEOUT,
|
||||
url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL]
|
||||
)
|
||||
|
||||
calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT)
|
||||
@@ -237,7 +231,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.coordinator.calendar.add_event, **item_data),
|
||||
)
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, 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, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, 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, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, 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, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, 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, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, 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, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV delete error: {err}") from err
|
||||
# refreshing async otherwise it would take too much time
|
||||
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
"""Provide functionality to keep track of devices."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .config_entry import ( # noqa: F401
|
||||
DATA_COMPONENT,
|
||||
BaseScannerEntity,
|
||||
BaseTrackerEntity,
|
||||
ScannerEntity,
|
||||
ScannerEntityDescription,
|
||||
TrackerEntity,
|
||||
@@ -40,8 +33,6 @@ from .const import ( # noqa: F401
|
||||
DEFAULT_TRACK_NEW,
|
||||
DOMAIN,
|
||||
ENTITY_ID_FORMAT,
|
||||
LOGGER,
|
||||
PLATFORM_TYPE_LEGACY,
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
)
|
||||
@@ -53,9 +44,7 @@ from .legacy import ( # noqa: F401
|
||||
SOURCE_TYPES,
|
||||
AsyncSeeCallback,
|
||||
DeviceScanner,
|
||||
DeviceTracker,
|
||||
SeeCallback,
|
||||
async_create_platform_type,
|
||||
async_setup_integration as async_setup_legacy_integration,
|
||||
see,
|
||||
)
|
||||
@@ -68,43 +57,5 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool:
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the device tracker."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
|
||||
LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
)
|
||||
component.config = {}
|
||||
component.register_shutdown()
|
||||
|
||||
# The tracker is loaded in the async_setup_legacy_integration task so
|
||||
# we create a future to avoid waiting on it here so that only
|
||||
# async_platform_discovered will have to wait in the rare event
|
||||
# a custom component still uses the legacy device tracker discovery.
|
||||
tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future()
|
||||
|
||||
async def async_platform_discovered(
|
||||
p_type: str, info: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Load a platform."""
|
||||
platform = await async_create_platform_type(hass, config, p_type, {})
|
||||
|
||||
if platform is None:
|
||||
return
|
||||
|
||||
if platform.type != PLATFORM_TYPE_LEGACY:
|
||||
await component.async_setup_platform(p_type, {}, info)
|
||||
return
|
||||
|
||||
tracker = await tracker_future
|
||||
await platform.async_setup_legacy(hass, tracker, info)
|
||||
|
||||
discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
|
||||
#
|
||||
# Legacy and platforms load in a non-awaited tracked task
|
||||
# to ensure device tracker setup can continue and config
|
||||
# entry integrations are not waiting for legacy device
|
||||
# tracker platforms to be set up.
|
||||
#
|
||||
hass.async_create_task(
|
||||
async_setup_legacy_integration(hass, config, tracker_future),
|
||||
eager_start=True,
|
||||
)
|
||||
async_setup_legacy_integration(hass, config)
|
||||
return True
|
||||
|
||||
@@ -37,7 +37,11 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
discovery,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_time_interval,
|
||||
async_track_utc_time_change,
|
||||
@@ -200,7 +204,40 @@ def see(
|
||||
hass.services.call(DOMAIN, SERVICE_SEE, data)
|
||||
|
||||
|
||||
async def async_setup_integration(
|
||||
@callback
|
||||
def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Set up the legacy integration."""
|
||||
# The tracker is loaded in the _async_setup_integration task so
|
||||
# we create a future to avoid waiting on it here so that only
|
||||
# async_platform_discovered will have to wait in the rare event
|
||||
# a custom component still uses the legacy device tracker discovery.
|
||||
tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future()
|
||||
|
||||
async def async_platform_discovered(
|
||||
p_type: str, info: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Load a platform."""
|
||||
platform = await async_create_platform_type(hass, config, p_type, {})
|
||||
|
||||
if platform is None or platform.type != PLATFORM_TYPE_LEGACY:
|
||||
return
|
||||
|
||||
tracker = await tracker_future
|
||||
await platform.async_setup_legacy(hass, tracker, info)
|
||||
|
||||
discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
|
||||
#
|
||||
# Legacy and platforms load in a non-awaited tracked task
|
||||
# to ensure device tracker setup can continue and config
|
||||
# entry integrations are not waiting for legacy device
|
||||
# tracker platforms to be set up.
|
||||
#
|
||||
hass.async_create_task(
|
||||
_async_setup_integration(hass, config, tracker_future), eager_start=True
|
||||
)
|
||||
|
||||
|
||||
async def _async_setup_integration(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
tracker_future: asyncio.Future[DeviceTracker],
|
||||
|
||||
@@ -31,7 +31,6 @@ class EkeyEvent(EventEntity):
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_event_types = ["event happened"]
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -509,12 +509,14 @@ 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,
|
||||
)
|
||||
@@ -652,7 +654,6 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
|
||||
entity_description: FitbitSensorEntityDescription
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -712,7 +713,6 @@ class FitbitBatteryLevelSensor(
|
||||
"""Implementation of a Fitbit battery level sensor."""
|
||||
|
||||
entity_description: FitbitSensorEntityDescription
|
||||
_attr_has_entity_name = True
|
||||
_attr_attribution = ATTRIBUTION
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -438,7 +438,6 @@ 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."""
|
||||
|
||||
@@ -605,7 +604,6 @@ 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)) is not None:
|
||||
if interval := call.data.get(ATTR_INTERVAL):
|
||||
data["everyX"] = interval
|
||||
|
||||
if (streak := call.data.get(ATTR_STREAK)) is not None:
|
||||
if streak := call.data.get(ATTR_STREAK):
|
||||
data["streak"] = streak
|
||||
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor import SupervisorBadRequestError, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
GreenOptions,
|
||||
HomeAssistantOptions,
|
||||
@@ -25,6 +25,7 @@ from homeassistant.components.http import (
|
||||
CONF_SERVER_PORT,
|
||||
CONF_SSL_CERTIFICATE,
|
||||
)
|
||||
from homeassistant.components.onboarding import async_is_onboarded
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
@@ -301,6 +302,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
translation_key="supervisor_not_connected",
|
||||
) from err
|
||||
|
||||
# During onboarding, Supervisor may be out of date. Attempt an update now
|
||||
# so that core loads against an up-to-date Supervisor. A
|
||||
# SupervisorBadRequestError means there is no update available, proceed
|
||||
# normally. No exception means an update was triggered and we must wait for
|
||||
# it to complete. Any other SupervisorError means something unexpected went
|
||||
# wrong and we cannot proceed right now.
|
||||
if not async_is_onboarded(hass):
|
||||
try:
|
||||
await supervisor_client.supervisor.update()
|
||||
except SupervisorBadRequestError:
|
||||
pass # No update available, proceed normally.
|
||||
except SupervisorError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_connected",
|
||||
) from err
|
||||
else:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_update_pending",
|
||||
)
|
||||
|
||||
# Get or create a refresh token for the Supervisor user
|
||||
user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
|
||||
if user.refresh_tokens:
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
},
|
||||
"supervisor_not_connected": {
|
||||
"message": "Not connected with the supervisor / system too busy"
|
||||
},
|
||||
"supervisor_update_pending": {
|
||||
"message": "Supervisor update in progress, will retry when complete"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -266,12 +266,14 @@ 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,24 +180,27 @@ 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."""
|
||||
# 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 ""
|
||||
# Adds a sensor to the existing HAP device
|
||||
return DeviceInfo(
|
||||
identifiers={
|
||||
# Serial numbers of Homematic IP device
|
||||
(DOMAIN, self._home.id)
|
||||
},
|
||||
name=label,
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -576,7 +579,6 @@ 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,7 +74,6 @@ 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,7 +320,6 @@ 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,7 +74,6 @@ GROUP_ATTRIBUTES = {
|
||||
class HomematicipGenericEntity(Entity):
|
||||
"""Representation of the HomematicIP generic entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
@@ -113,14 +112,6 @@ 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."""
|
||||
@@ -129,14 +120,6 @@ 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
|
||||
@@ -144,7 +127,7 @@ class HomematicipGenericEntity(Entity):
|
||||
},
|
||||
manufacturer=self._device.oem,
|
||||
model=self._device.modelType,
|
||||
name=device_name,
|
||||
name=self._device.label,
|
||||
sw_version=self._device.firmwareVersion,
|
||||
# Link to the homematic ip access point.
|
||||
via_device=(DOMAIN, home_id),
|
||||
@@ -217,93 +200,38 @@ class HomematicipGenericEntity(Entity):
|
||||
self.async_remove(force_remove=True), eager_start=False
|
||||
)
|
||||
|
||||
def _compute_legacy_name(self) -> str:
|
||||
"""Compute the full legacy name for entities without has_entity_name.
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the generic entity."""
|
||||
|
||||
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
|
||||
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.
|
||||
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,6 +82,7 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
|
||||
super().__init__(
|
||||
hap,
|
||||
device,
|
||||
post=description.key,
|
||||
channel=channel,
|
||||
is_multi_channel=False,
|
||||
feature_id="doorbell",
|
||||
|
||||
@@ -1070,7 +1070,9 @@ class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity):
|
||||
description: HmipSmokeDetectorSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the smoke detector sensor."""
|
||||
super().__init__(hap, device, feature_id="smoke_detector_sensor")
|
||||
super().__init__(
|
||||
hap, device, post=description.key, feature_id="smoke_detector_sensor"
|
||||
)
|
||||
self.entity_description = description
|
||||
self._sensor_unique_id = f"{device.id}_{description.key}"
|
||||
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"cloud_connection": {
|
||||
"name": "Cloud connection"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"optical_signal_light": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -142,8 +142,6 @@ 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,6 +74,11 @@ 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."""
|
||||
@@ -113,7 +118,6 @@ 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,7 +16,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_COMMAND,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
@@ -40,6 +39,9 @@ _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,9 +73,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) ->
|
||||
except InvalidHeaterList as exc:
|
||||
raise NoHeaters from exc
|
||||
except InvalidGateway as exc:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="incorrect_credentials"
|
||||
) from exc
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed("Incorrect credentials") from exc
|
||||
except ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
raise NotFound from exc
|
||||
|
||||
@@ -15,12 +15,10 @@ from incomfortclient import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
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__)
|
||||
@@ -79,20 +77,16 @@ 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:
|
||||
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
|
||||
# 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
|
||||
except InvalidHeaterList as exc:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_with_error_message",
|
||||
translation_placeholders={"error": exc.message},
|
||||
) from exc
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed(exc.message) from exc
|
||||
return self.incomfort_data
|
||||
|
||||
@@ -131,7 +131,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"incorrect_credentials": { "message": "Incorrect credentials." },
|
||||
"no_heaters": {
|
||||
"message": "[%key:component::incomfort::config::error::no_heaters%]"
|
||||
},
|
||||
@@ -143,9 +142,6 @@
|
||||
},
|
||||
"unknown": {
|
||||
"message": "[%key:component::incomfort::config::error::unknown%]"
|
||||
},
|
||||
"update_failed_with_error_message": {
|
||||
"message": "Update failed, got {error}."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -72,11 +72,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
config_data = await self.api.get_config()
|
||||
except (ClientError, OSError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err
|
||||
|
||||
# Cache device information
|
||||
device_data = config_data.get("device", {})
|
||||
@@ -91,11 +87,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
return await self.api.fetch_data(sensor_keys)
|
||||
except (ClientError, OSError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Device update failed: {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."""
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/indevolt",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["indevolt-api==1.8.1"]
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
|
||||
@@ -373,9 +373,6 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -403,9 +400,6 @@
|
||||
"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,8 +556,9 @@ 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"
|
||||
@@ -889,8 +890,10 @@ 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
|
||||
|
||||
@@ -77,6 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
|
||||
await coordinator_info.async_config_entry_first_refresh()
|
||||
|
||||
if info_api.data is None or info_api.serial_number is None:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN, translation_key="missing_device_info"
|
||||
)
|
||||
|
||||
@@ -216,8 +216,6 @@ 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__(
|
||||
|
||||
@@ -35,10 +35,5 @@
|
||||
"description": "Enter the URL of your Lunatone device.\nHome Assistant will use this address to connect to the device API."
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"missing_device_info": {
|
||||
"message": "Unable to read device information. Please verify the device's network connection."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -22,6 +22,10 @@ _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,6 +10,8 @@ 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,13 +9,7 @@ import librouteros
|
||||
from librouteros.login import plain as login_plain, token as login_token
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.const import 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
|
||||
@@ -23,6 +17,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
from .const import (
|
||||
ARP,
|
||||
ATTR_FIRMWARE,
|
||||
ATTR_MODEL,
|
||||
ATTR_SERIAL_NUMBER,
|
||||
CAPSMAN,
|
||||
CONF_ARP_PING,
|
||||
|
||||
@@ -11,29 +11,27 @@ publish:
|
||||
example: "The temperature is {{ states('sensor.temperature') }}"
|
||||
selector:
|
||||
template:
|
||||
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
|
||||
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,12 +1578,7 @@
|
||||
"name": "Topic"
|
||||
}
|
||||
},
|
||||
"name": "Publish",
|
||||
"sections": {
|
||||
"publish_options": {
|
||||
"name": "Publish options"
|
||||
}
|
||||
}
|
||||
"name": "Publish"
|
||||
},
|
||||
"reload": {
|
||||
"description": "Reloads MQTT entities from the YAML-configuration.",
|
||||
|
||||
@@ -23,7 +23,7 @@ from music_assistant_models.errors import (
|
||||
from music_assistant_models.player import Player
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
@@ -38,7 +38,7 @@ from homeassistant.helpers.issue_registry import (
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
|
||||
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, CONF_TOKEN, DOMAIN, LOGGER
|
||||
from .helpers import get_music_assistant_client
|
||||
from .services import register_actions
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
@@ -31,7 +31,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import AUTH_SCHEMA_VERSION, DOMAIN, HASSIO_DISCOVERY_SCHEMA_VERSION, LOGGER
|
||||
from .const import (
|
||||
AUTH_SCHEMA_VERSION,
|
||||
CONF_TOKEN,
|
||||
DOMAIN,
|
||||
HASSIO_DISCOVERY_SCHEMA_VERSION,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
DEFAULT_TITLE = "Music Assistant"
|
||||
DEFAULT_URL = "http://mass.local:8095"
|
||||
|
||||
@@ -12,6 +12,9 @@ AUTH_SCHEMA_VERSION = 28
|
||||
# Schema version where hassio discovery support was added
|
||||
HASSIO_DISCOVERY_SCHEMA_VERSION = 28
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_TOKEN = "token"
|
||||
|
||||
ATTR_IS_GROUP = "is_group"
|
||||
ATTR_GROUP_MEMBERS = "group_members"
|
||||
ATTR_GROUP_PARENTS = "group_parents"
|
||||
|
||||
@@ -46,6 +46,7 @@ play_media:
|
||||
- "add"
|
||||
translation_key: enqueue
|
||||
radio_mode:
|
||||
advanced: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
@@ -137,21 +138,20 @@ search:
|
||||
example: "News of the world"
|
||||
selector:
|
||||
text:
|
||||
search_options:
|
||||
fields:
|
||||
limit:
|
||||
example: 25
|
||||
default: 5
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 100
|
||||
step: 1
|
||||
library_only:
|
||||
example: "true"
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
limit:
|
||||
advanced: true
|
||||
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:
|
||||
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
|
||||
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
|
||||
order_by:
|
||||
example: "random"
|
||||
selector:
|
||||
|
||||
@@ -360,12 +360,7 @@
|
||||
"name": "Search"
|
||||
}
|
||||
},
|
||||
"name": "Get library items",
|
||||
"sections": {
|
||||
"pagination": {
|
||||
"name": "Pagination"
|
||||
}
|
||||
}
|
||||
"name": "Get library items"
|
||||
},
|
||||
"get_queue": {
|
||||
"description": "Retrieves the details of the currently active queue of a Music Assistant player.",
|
||||
@@ -455,12 +450,7 @@
|
||||
"name": "Search name"
|
||||
}
|
||||
},
|
||||
"name": "Search Music Assistant",
|
||||
"sections": {
|
||||
"search_options": {
|
||||
"name": "Search options"
|
||||
}
|
||||
}
|
||||
"name": "Search Music Assistant"
|
||||
},
|
||||
"transfer_queue": {
|
||||
"description": "Transfers a player's queue to another player.",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Virtual integration: National Grid US."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"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: todo
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
|
||||
@@ -23,6 +23,7 @@ _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}",
|
||||
@@ -35,7 +36,6 @@ 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/*
|
||||
additional_fields:
|
||||
advanced_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
rotation:
|
||||
|
||||
@@ -151,8 +151,8 @@
|
||||
},
|
||||
"name": "Upload image",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "Additional options"
|
||||
"advanced_options": {
|
||||
"name": "Advanced options"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz==1.20.4"],
|
||||
"requirements": ["pyoverkiz==1.20.3"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "gateway*",
|
||||
|
||||
@@ -15,6 +15,8 @@ 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 ATTR_MANUFACTURER, CONF_NAME
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -23,6 +23,7 @@ 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 ATTR_MANUFACTURER, CONF_NAME, STATE_ON
|
||||
from homeassistant.const import CONF_NAME, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from esios_api import DEFAULT_POWER_KW, PVPCData
|
||||
from aiopvpc import DEFAULT_POWER_KW, PVPCData
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -63,10 +63,9 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[ATTR_TARIFF])
|
||||
self._abort_if_unique_id_configured()
|
||||
calc_name = f"{DEFAULT_NAME} - {user_input[ATTR_TARIFF]}"
|
||||
if not user_input[CONF_USE_API_TOKEN]:
|
||||
return self.async_create_entry(
|
||||
title=calc_name,
|
||||
title=DEFAULT_NAME,
|
||||
data={
|
||||
ATTR_TARIFF: user_input[ATTR_TARIFF],
|
||||
ATTR_POWER: user_input[ATTR_POWER],
|
||||
@@ -75,7 +74,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
self._name = calc_name
|
||||
self._name = DEFAULT_NAME
|
||||
self._tariff = user_input[ATTR_TARIFF]
|
||||
self._power = user_input[ATTR_POWER]
|
||||
self._power_p3 = user_input[ATTR_POWER_P3]
|
||||
@@ -151,7 +150,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle re-authentication with ESIOS Token."""
|
||||
self._api_token = entry_data.get(CONF_API_TOKEN)
|
||||
self._use_api_token = self._api_token is not None
|
||||
self._name = f"{DEFAULT_NAME} - {entry_data[ATTR_TARIFF]}"
|
||||
self._name = DEFAULT_NAME
|
||||
self._tariff = entry_data[ATTR_TARIFF]
|
||||
self._power = entry_data[ATTR_POWER]
|
||||
self._power_p3 = entry_data[ATTR_POWER_P3]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Constant values for pvpc_hourly_pricing."""
|
||||
|
||||
from esios_api.const import TARIFFS
|
||||
from aiopvpc.const import TARIFFS
|
||||
import voluptuous as vol
|
||||
|
||||
DOMAIN = "pvpc_hourly_pricing"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from esios_api import BadApiTokenAuthError, EsiosApiData, PVPCData
|
||||
from aiopvpc import BadApiTokenAuthError, EsiosApiData, PVPCData
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Helper functions to relate sensors keys and unique ids."""
|
||||
|
||||
from esios_api.const import (
|
||||
from aiopvpc.const import (
|
||||
ALL_SENSORS,
|
||||
KEY_INJECTION,
|
||||
KEY_MAG,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"domain": "pvpc_hourly_pricing",
|
||||
"name": "Spain electricity hourly pricing (PVPC)",
|
||||
"codeowners": ["@azogue", "@chiro79"],
|
||||
"codeowners": ["@azogue"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["esios_api"],
|
||||
"requirements": ["esios_api==4.4.0"]
|
||||
"loggers": ["aiopvpc"],
|
||||
"requirements": ["aiopvpc==4.3.1"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from esios_api.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC
|
||||
from aiopvpc.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
|
||||
@@ -4,7 +4,7 @@ rules:
|
||||
brands: done
|
||||
dependency-transparency: done
|
||||
common-modules: done
|
||||
has-entity-name: todo
|
||||
has-entity-name: done
|
||||
action-setup:
|
||||
status: done
|
||||
comment: |
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["renault_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["renault-api==0.5.10"]
|
||||
"requirements": ["renault-api==0.5.9"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/risco",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyrisco"],
|
||||
"requirements": ["pyrisco==0.7.0"]
|
||||
"requirements": ["pyrisco==0.6.8"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "samsungtv",
|
||||
"name": "Samsung Smart TV",
|
||||
"codeowners": ["@chemelli74"],
|
||||
"codeowners": ["@chemelli74", "@epenet"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"dhcp": [
|
||||
|
||||
@@ -401,6 +401,7 @@ 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",
|
||||
@@ -670,6 +671,7 @@ 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,7 +2,6 @@
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import hashlib
|
||||
@@ -39,15 +38,6 @@ 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
|
||||
|
||||
|
||||
@@ -112,9 +102,6 @@ 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,
|
||||
@@ -228,11 +215,9 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_image_hash(self) -> str | None:
|
||||
"""Hash value for media image."""
|
||||
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]
|
||||
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
|
||||
|
||||
def _get_updated_media_position(self) -> int | None:
|
||||
"""Return the current playback position and update its timestamp."""
|
||||
@@ -250,11 +235,15 @@ 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.get("thumb")
|
||||
if not thumb or (result := self._decode_image_data(thumb)) is None:
|
||||
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:
|
||||
return await super().async_get_media_image()
|
||||
|
||||
return result
|
||||
return image, mime
|
||||
|
||||
@rpc_call
|
||||
async def async_media_play(self) -> None:
|
||||
@@ -445,25 +434,3 @@ 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 {device} did not update within {period} seconds interval"
|
||||
"message": "Sleeping device did not update within {period} seconds interval"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -344,10 +344,14 @@ 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={"device": self.coordinator.name},
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
"device": self.coordinator.name,
|
||||
},
|
||||
) from err
|
||||
except InvalidAuthError:
|
||||
await self.coordinator.async_shutdown_device_and_start_reauth()
|
||||
|
||||
@@ -69,14 +69,18 @@ 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:
|
||||
@@ -87,14 +91,18 @@ 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"
|
||||
"message": "Could not connect to SMA device - {error}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Invalid authentication for SMA device"
|
||||
"message": "Invalid authentication for SMA device - {error}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -25,8 +25,6 @@ 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=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
)
|
||||
],
|
||||
},
|
||||
@@ -447,7 +447,7 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
key=Attribute.COMPLETION_TIME,
|
||||
translation_key="completion_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
)
|
||||
],
|
||||
},
|
||||
@@ -565,7 +565,7 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
key=Attribute.GAS_METER_TIME,
|
||||
translation_key="gas_meter_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
)
|
||||
],
|
||||
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=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
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=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
component_fn=lambda component: component == "sub",
|
||||
component_translation_key={
|
||||
"sub": "washer_sub_completion_time",
|
||||
|
||||
@@ -9,6 +9,8 @@ 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,7 +24,6 @@ from homeassistant.components.notify import (
|
||||
BaseNotificationService,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_DEBUG,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_RECIPIENT,
|
||||
@@ -45,6 +44,7 @@ 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,7 +195,6 @@ 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 ATTR_MODE, MAX_LENGTH_STATE_STATE
|
||||
from homeassistant.const import 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,6 +23,7 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from .const import (
|
||||
ATTR_MAX,
|
||||
ATTR_MIN,
|
||||
ATTR_MODE,
|
||||
ATTR_PATTERN,
|
||||
ATTR_VALUE,
|
||||
DOMAIN,
|
||||
|
||||
@@ -4,6 +4,8 @@ DOMAIN = "text"
|
||||
|
||||
ATTR_MAX = "max"
|
||||
ATTR_MIN = "min"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODE = "mode"
|
||||
ATTR_PATTERN = "pattern"
|
||||
ATTR_VALUE = "value"
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ class OmadaClientScannerEntity(
|
||||
):
|
||||
"""Entity for a client connected to the Omada network."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_client_details: OmadaWirelessClient | None = None
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -51,16 +51,14 @@ 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(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"host": entry.data[CONF_HOST]},
|
||||
f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}"
|
||||
) from err
|
||||
except ApiConnectionError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"host": entry.data[CONF_HOST]},
|
||||
f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}"
|
||||
) from err
|
||||
|
||||
coordinator = UnifiAccessCoordinator(hass, entry, client)
|
||||
|
||||
@@ -197,25 +197,17 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
|
||||
self.client.get_emergency_status(),
|
||||
)
|
||||
except ApiAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_auth",
|
||||
) from err
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
|
||||
except ApiConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_connection",
|
||||
) from err
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed(f"Error connecting to API: {err}") from err
|
||||
except ApiError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_api",
|
||||
) from err
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
except TimeoutError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_timeout",
|
||||
) from err
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed("Timeout communicating with UniFi Access API") from err
|
||||
|
||||
previous_lock_rules = self.data.door_lock_rules.copy() if self.data else {}
|
||||
door_lock_rules: dict[str, DoorLockRuleStatus] = {}
|
||||
|
||||
@@ -133,12 +133,6 @@
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
@@ -156,18 +150,6 @@
|
||||
},
|
||||
"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,10 +84,8 @@ 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:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_auth_failed",
|
||||
) from err
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except (TimeoutError, ClientError, ServerDisconnectedError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
@@ -105,14 +105,12 @@ def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]:
|
||||
|
||||
@callback
|
||||
def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn:
|
||||
exc = BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_identifier",
|
||||
translation_placeholders={"identifier": identifier},
|
||||
)
|
||||
msg = f"Unexpected identifier: {identifier}"
|
||||
if err is None:
|
||||
raise exc
|
||||
raise exc from err
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BrowseError(msg)
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BrowseError(msg) from err
|
||||
|
||||
|
||||
@callback
|
||||
@@ -381,10 +379,8 @@ 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:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="event_ongoing",
|
||||
)
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BrowseError("Event is still ongoing")
|
||||
|
||||
return await self._build_event(data, event, thumbnail_only)
|
||||
|
||||
@@ -794,11 +790,8 @@ class ProtectMediaSource(MediaSource):
|
||||
if camera_id != "all":
|
||||
camera = data.api.bootstrap.cameras.get(camera_id)
|
||||
if camera is None:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_camera_id",
|
||||
translation_placeholders={"camera_id": camera_id},
|
||||
)
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BrowseError(f"Unknown 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,12 +679,6 @@
|
||||
"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."
|
||||
},
|
||||
@@ -723,12 +717,6 @@
|
||||
},
|
||||
"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,9 +30,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_access_or_create_backup_path": {
|
||||
"message": "Cannot access or create backup path"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to WebDAV server"
|
||||
},
|
||||
|
||||
@@ -48,10 +48,14 @@ 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)},
|
||||
translation_placeholders={
|
||||
"name": str(self._entry.title),
|
||||
"func": __name__,
|
||||
},
|
||||
)
|
||||
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.23.0"],
|
||||
"requirements": ["wled==0.22.0"],
|
||||
"zeroconf": ["_wled._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Support for Wyoming intent recognition services."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -8,7 +7,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, IntentsStart, IntentsStop, NotRecognized
|
||||
from wyoming.intent import Intent, NotRecognized
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.const import MATCH_ALL
|
||||
@@ -87,10 +86,6 @@ 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"
|
||||
@@ -170,27 +165,62 @@ 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):
|
||||
intents.append(Intent.from_event(event))
|
||||
if not has_intents_list:
|
||||
# Only one intent, no need to wait
|
||||
break
|
||||
# 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)
|
||||
|
||||
if IntentsStop.is_type(event.type):
|
||||
# End of intents list
|
||||
break
|
||||
|
||||
if NotRecognized.is_type(event.type):
|
||||
@@ -200,9 +230,6 @@ 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):
|
||||
@@ -220,107 +247,6 @@ 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,9 +14,10 @@ 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
|
||||
|
||||
return {
|
||||
payload: dict[str, Any] = {
|
||||
"wifi_enabled": data.wifi_enabled,
|
||||
"serial_or_registry_id": data.serial_or_registry_id,
|
||||
"registry_key": data.registry_key,
|
||||
@@ -32,6 +33,8 @@ 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
|
||||
@@ -39,13 +42,15 @@ 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": (
|
||||
None
|
||||
if coordinator.update_interval is None
|
||||
else coordinator.update_interval.total_seconds()
|
||||
),
|
||||
"update_interval": updateInterval,
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ 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"
|
||||
|
||||
@@ -4613,6 +4613,11 @@
|
||||
"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,7 +38,6 @@ from homeassistant.const import (
|
||||
CONF_ATTRIBUTE,
|
||||
CONF_BELOW,
|
||||
CONF_CHOOSE,
|
||||
CONF_COMMENT,
|
||||
CONF_CONDITION,
|
||||
CONF_CONDITIONS,
|
||||
CONF_CONTINUE_ON_ERROR,
|
||||
@@ -1459,7 +1458,6 @@ 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),
|
||||
}
|
||||
@@ -1527,7 +1525,6 @@ 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),
|
||||
}
|
||||
|
||||
@@ -1862,7 +1859,6 @@ 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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user