mirror of
https://github.com/home-assistant/core.git
synced 2026-05-21 08:15:14 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c8cc3acb9 | |||
| b0634bea35 | |||
| 5ae31cad6f | |||
| b45aaaa177 | |||
| 6560496440 | |||
| 489dda8efb | |||
| 30c942d139 | |||
| c735e47e23 | |||
| 3856405c72 | |||
| 323479ca44 | |||
| c8bfe56975 | |||
| ab214b64f2 | |||
| fea673d93a | |||
| 5405151112 | |||
| b3c210ef24 | |||
| 5f5d74cfbd | |||
| c188fdcc8b | |||
| a3b43fc19b | |||
| 894a68acb6 | |||
| 30bc3fc412 | |||
| 3cc0cc38ab | |||
| 296caa90c1 | |||
| bb4c211fb6 | |||
| d4fa904386 | |||
| db98f0b434 | |||
| 7341ac91ee | |||
| b2fb5df0fb | |||
| 265485a7d0 | |||
| bf1b93fb66 | |||
| be9d4bedfd |
@@ -19,7 +19,6 @@ machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
requirements_all.txt linguist-generated=true
|
||||
requirements_test_all.txt linguist-generated=true
|
||||
requirements_test_pre_commit.txt linguist-generated=true
|
||||
script/hassfest/docker/Dockerfile linguist-generated=true
|
||||
.github/workflows/*.lock.yml linguist-generated=true
|
||||
|
||||
+55
-23
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1ad29b8fb97f5df4466be54051779a3188f094d7efb041a8ed55211eab33c5f5","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -65,7 +65,9 @@ run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs: pre_activation
|
||||
needs:
|
||||
- extract_pr_number
|
||||
- pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
@@ -189,20 +191,20 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -231,12 +233,12 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -314,7 +316,9 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
agent:
|
||||
needs: activation
|
||||
needs:
|
||||
- activation
|
||||
- extract_pr_number
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -385,11 +389,6 @@ jobs:
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/gh-aw/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
- if: github.event.workflow_run.conclusion == 'success'
|
||||
name: Extract PR number from artifact
|
||||
run: |-
|
||||
PR=$(python3 -c 'import json,sys;print(json.load(open("/tmp/gh-aw/deterministic/results.json"))["pr_number"])')
|
||||
echo "PR_NUMBER=${PR}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Configure Git credentials
|
||||
env:
|
||||
@@ -454,15 +453,15 @@ jobs:
|
||||
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
|
||||
mkdir -p /tmp/gh-aw/safeoutputs
|
||||
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_c09f5151c817ddfc_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ env.PR_NUMBER }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_c09f5151c817ddfc_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
{
|
||||
"description_suffixes": {
|
||||
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ env.PR_NUMBER }}. Supports reply_to_id for discussion threading."
|
||||
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ needs.extract_pr_number.outputs.pr_number }}. Supports reply_to_id for discussion threading."
|
||||
},
|
||||
"repo_params": {},
|
||||
"dynamic_tools": []
|
||||
@@ -648,7 +647,7 @@ jobs:
|
||||
|
||||
mkdir -p /home/runner/.copilot
|
||||
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
|
||||
cat << GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -692,7 +691,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -939,6 +938,7 @@ jobs:
|
||||
- activation
|
||||
- agent
|
||||
- detection
|
||||
- extract_pr_number
|
||||
- safe_outputs
|
||||
if: >
|
||||
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
|
||||
@@ -1283,6 +1283,37 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
|
||||
outputs:
|
||||
pr_number: ${{ steps.extract.outputs.pr_number }}
|
||||
steps:
|
||||
- name: Configure GH_HOST for enterprise compatibility
|
||||
id: ghes-host-config
|
||||
shell: bash
|
||||
run: |
|
||||
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
|
||||
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
|
||||
GH_HOST="${GITHUB_SERVER_URL#https://}"
|
||||
GH_HOST="${GH_HOST#http://}"
|
||||
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
|
||||
- name: Download deterministic-results artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
- name: Extract PR number from artifact
|
||||
id: extract
|
||||
run: |
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
pre_activation:
|
||||
runs-on: ubuntu-slim
|
||||
outputs:
|
||||
@@ -1321,6 +1352,7 @@ jobs:
|
||||
- activation
|
||||
- agent
|
||||
- detection
|
||||
- extract_pr_number
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
@@ -1393,7 +1425,7 @@ jobs:
|
||||
GH_AW_ALLOWED_DOMAINS: "*.pythonhosted.org,anaconda.org,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,binstar.org,bootstrap.pypa.io,conda.anaconda.org,conda.binstar.org,files.pythonhosted.org,github.com,host.docker.internal,pip.pypa.io,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.npmjs.org,repo.anaconda.com,repo.continuum.io,telemetry.enterprise.githubcopilot.com"
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ env.PR_NUMBER }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ needs.extract_pr_number.outputs.pr_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -19,7 +19,30 @@ tools:
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
max: 1
|
||||
target: "${{ env.PR_NUMBER }}"
|
||||
target: "${{ needs.extract_pr_number.outputs.pr_number }}"
|
||||
needs:
|
||||
- extract_pr_number
|
||||
jobs:
|
||||
extract_pr_number:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
outputs:
|
||||
pr_number: ${{ steps.extract.outputs.pr_number }}
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract PR number from artifact
|
||||
id: extract
|
||||
run: |
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
@@ -32,11 +55,6 @@ steps:
|
||||
path: /tmp/gh-aw/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract PR number from artifact
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
run: |
|
||||
PR=$(python3 -c 'import json,sys;print(json.load(open("/tmp/gh-aw/deterministic/results.json"))["pr_number"])')
|
||||
echo "PR_NUMBER=${PR}" >> "${GITHUB_ENV}"
|
||||
post-steps:
|
||||
- name: Verify agent produced an add_comment safe-output
|
||||
if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
@@ -80,10 +98,11 @@ The deterministic stage uploaded its results to the runner at
|
||||
The JSON has this shape:
|
||||
|
||||
- `pr_number` — the PR being checked. The `add_comment` safe-output is
|
||||
already targeted at this PR (the workflow extracted `pr_number` from
|
||||
the artifact and wired it into the safe-output config), so **you do
|
||||
not need to set `item_number` yourself** — just emit `add_comment`
|
||||
with the rendered body.
|
||||
already targeted at this PR (a pre-job extracts `pr_number` from the
|
||||
artifact and the workflow wires it into the safe-output config via
|
||||
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
|
||||
set `item_number` yourself** — just emit `add_comment` with the
|
||||
rendered body.
|
||||
- `needs_agent` — `true` iff any package's check needs resolution.
|
||||
- `packages[]` — one entry per changed package. Each entry has:
|
||||
- `name`, `old_version` (`null` for a newly added package; otherwise the
|
||||
@@ -161,9 +180,10 @@ Verify that the package's source repository is publicly reachable.
|
||||
- Any other inconclusive result → ⚠️ with a one-line description.
|
||||
|
||||
If `repo_public` resolves to ❌ for a package, **also** mark that
|
||||
package's `release_pipeline` cell/detail as `—` (em dash) and explain
|
||||
`Skipped because the source repository is not publicly accessible.` —
|
||||
because the release pipeline cannot be inspected without a public repo.
|
||||
package's `release_pipeline` and `async_blocking` cells/details as `—`
|
||||
(em dash) and explain `Skipped because the source repository is not
|
||||
publicly accessible.` — neither check can be performed without a public
|
||||
repo.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
@@ -239,6 +259,111 @@ host from `package.repo_url`, then apply the corresponding checklist.
|
||||
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
|
||||
inspected; hosting provider is not GitHub or GitLab.`
|
||||
|
||||
### Check kind: `async_blocking`
|
||||
|
||||
Verify whether the dependency performs blocking I/O inside async code
|
||||
paths. Home Assistant runs on a single asyncio event loop, so a library
|
||||
that exposes an `async` surface must not call blocking APIs from inside
|
||||
its `async def` functions — that stalls the whole loop. A purely sync
|
||||
library is fine: Home Assistant integrations are expected to wrap such
|
||||
calls in an executor.
|
||||
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
|
||||
- `old_version` is `null` → **new package**: review the *entire current
|
||||
source tree*. Nothing about this dependency has been vetted before.
|
||||
- `old_version` is a string → **version bump**: review only the *diff
|
||||
between `old_version` and `new_version`*. The previous version was
|
||||
already accepted, so blocking calls that were present in
|
||||
`old_version` are not regressions; report only what `new_version`
|
||||
introduces.
|
||||
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
|
||||
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
|
||||
(other hosts) on `package.repo_url`. Always inspect the tag /
|
||||
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
|
||||
|
||||
- Locate the top-level package directory (usually named after the
|
||||
import name, often equal or close to `package.name`).
|
||||
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
|
||||
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
|
||||
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
|
||||
example in the README).
|
||||
- Grep the package source for `async def`. A handful of `async def`
|
||||
entries in the public modules is enough to treat the library as
|
||||
having an async surface.
|
||||
|
||||
If the library is **sync-only** (no `async def` in its public modules
|
||||
and no async framework dependency) → ✅
|
||||
`Sync-only library; Home Assistant integrations must wrap calls in an
|
||||
executor.` *This verdict is the same in both modes.*
|
||||
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
|
||||
Inspect **every `async def` in the public modules** for blocking
|
||||
patterns. Walk transitively into helpers the async functions call.
|
||||
|
||||
#### Step 2b — Mode: version bump (`old_version` is a string)
|
||||
|
||||
Fetch the diff between the two tags and review **only changed lines**:
|
||||
|
||||
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
|
||||
the `github` MCP tool, or
|
||||
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
|
||||
via `web-fetch`. Try the common tag formats in order until one
|
||||
resolves: `v{version}`, `{version}`, `release-{version}`.
|
||||
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
|
||||
- Other hosts: use the project's equivalent compare URL via
|
||||
`web-fetch`.
|
||||
|
||||
If neither tag format resolves on the host, fall back to a full review
|
||||
(Step 2a) and mention in the detail that the diff was unavailable.
|
||||
|
||||
When reviewing the diff, only flag blocking patterns that appear in
|
||||
**added lines** *inside or reachable from* an `async def`. A blocking
|
||||
call that existed in `old_version` and is unchanged is not a regression
|
||||
for this bump.
|
||||
|
||||
#### Step 3 — Blocking patterns to look for
|
||||
|
||||
In both modes, the patterns to flag inside `async def` bodies are:
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
|
||||
`AsyncClient`), `pycurl`.
|
||||
- `time.sleep(` (must be `await asyncio.sleep(`).
|
||||
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
|
||||
blocking `select.select`.
|
||||
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
|
||||
non-trivial sizes (small one-shot reads during import are
|
||||
acceptable; reads/writes on the request path are not — prefer
|
||||
`aiofiles` / executor).
|
||||
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
|
||||
`pymongo` (sync client), `redis.Redis` (sync client).
|
||||
- `subprocess.run` / `subprocess.call` / `os.system` (must be
|
||||
`asyncio.create_subprocess_*`).
|
||||
|
||||
A call that is clearly dispatched to an executor
|
||||
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
|
||||
does NOT count as blocking.
|
||||
|
||||
#### Step 4 — Verdict
|
||||
|
||||
- ✅ — no offending blocking pattern in the surface being reviewed
|
||||
(whole tree for a new package, added lines for a bump). For a bump,
|
||||
phrase the detail as `No new blocking calls introduced in
|
||||
{old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking calls exist only in sync helpers that the async API
|
||||
does not call, or only on a clearly non-hot path (e.g. one-shot
|
||||
setup before the event loop is running). Cite at least one
|
||||
`<file>:<line>` and explain why it is not on the hot path.
|
||||
- ❌ — a blocking call is reachable from an `async def` that is part
|
||||
of the public API on the request / polling path (for a bump: the
|
||||
call was introduced or moved onto the hot path by this version).
|
||||
Cite the offending `<file>:<line>` as a clickable link on the repo
|
||||
host so the contributor can jump to it.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive and helpful. Reference the inspected workflow / CI
|
||||
|
||||
Vendored
+3
-3
@@ -132,7 +132,7 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all Requirements",
|
||||
"label": "Install all production Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements_all.txt",
|
||||
"group": {
|
||||
@@ -146,9 +146,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all Test Requirements",
|
||||
"label": "Install all (test & production) Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
|
||||
"command": "uv pip install -r requirements_all.txt -r requirements_test.txt",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["py-aosmith==1.0.17"]
|
||||
"requirements": ["py-aosmith==1.0.18"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Config flow to configure the Arcam FMJ component."""
|
||||
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from arcam.fmj.client import Client, ConnectionFailed
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -29,26 +31,19 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
async def _async_check_and_create(self, host: str, port: int) -> ConfigFlowResult:
|
||||
async def _async_try_connect(self, host: str, port: int) -> None:
|
||||
"""Verify the device is reachable."""
|
||||
client = Client(host, port)
|
||||
try:
|
||||
await client.start()
|
||||
except ConnectionFailed:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
finally:
|
||||
await client.stop()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({host})",
|
||||
data={CONF_HOST: host, CONF_PORT: port},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
uuid = await get_uniqueid_from_host(
|
||||
async_get_clientsession(self.hass), user_input[CONF_HOST]
|
||||
@@ -58,18 +53,36 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], uuid
|
||||
)
|
||||
|
||||
return await self._async_check_and_create(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
try:
|
||||
await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
except socket.gaierror:
|
||||
errors["base"] = "invalid_host"
|
||||
except TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except ConnectionRefusedError:
|
||||
errors["base"] = "connection_refused"
|
||||
except ConnectionFailed, OSError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
|
||||
data={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
)
|
||||
|
||||
fields = {
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
schema = vol.Schema(fields)
|
||||
if user_input is not None:
|
||||
schema = self.add_suggested_values_to_schema(schema, user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -79,7 +92,10 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.context["title_placeholders"] = placeholders
|
||||
|
||||
if user_input is not None:
|
||||
return await self._async_check_and_create(self.host, self.port)
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({self.host})",
|
||||
data={CONF_HOST: self.host, CONF_PORT: self.port},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm", description_placeholders=placeholders
|
||||
@@ -97,6 +113,11 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
await self._async_set_unique_id_and_update(host, port, uuid)
|
||||
|
||||
try:
|
||||
await self._async_try_connect(host, port)
|
||||
except ConnectionFailed, OSError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.host = host
|
||||
self.port = DEFAULT_PORT
|
||||
self.port = port
|
||||
return await self.async_step_confirm()
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"connection_refused": "Host refused connection",
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
|
||||
},
|
||||
"flow_title": "{host}",
|
||||
"step": {
|
||||
"confirm": {
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed using the provided key ID and application key."
|
||||
},
|
||||
"invalid_bucket_name": {
|
||||
"message": "Bucket does not exist or is not writable by the provided credentials."
|
||||
},
|
||||
|
||||
@@ -45,6 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: CalDavConfigEntry) -> bo
|
||||
# on some other unexpected server response.
|
||||
_LOGGER.warning("Unexpected CalDAV server response: %s", err)
|
||||
return False
|
||||
except requests.Timeout as err:
|
||||
raise ConfigEntryNotReady("Timeout connecting to CalDAV server") from err
|
||||
except requests.ConnectionError as err:
|
||||
raise ConfigEntryNotReady("Connection error from CalDAV server") from err
|
||||
except DAVError as err:
|
||||
|
||||
@@ -38,6 +38,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import CalDavConfigEntry
|
||||
from .api import async_get_calendars
|
||||
from .const import TIMEOUT
|
||||
from .coordinator import CalDavUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -91,7 +92,12 @@ async def async_setup_platform(
|
||||
days = config[CONF_DAYS]
|
||||
|
||||
client = caldav.DAVClient(
|
||||
url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL]
|
||||
url,
|
||||
None,
|
||||
username,
|
||||
password,
|
||||
ssl_verify_cert=config[CONF_VERIFY_SSL],
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT)
|
||||
@@ -231,7 +237,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.coordinator.calendar.add_event, **item_data),
|
||||
)
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
|
||||
@callback
|
||||
|
||||
@@ -138,7 +138,7 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
)
|
||||
# refreshing async otherwise it would take too much time
|
||||
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
@@ -150,7 +150,7 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
)
|
||||
except NotFoundError as err:
|
||||
raise HomeAssistantError(f"Could not find To-do item {uid}") from err
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
|
||||
vtodo = todo.icalendar_component # type: ignore[attr-defined]
|
||||
vtodo["SUMMARY"] = item.summary or ""
|
||||
@@ -174,7 +174,7 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
)
|
||||
# refreshing async otherwise it would take too much time
|
||||
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
@@ -188,14 +188,14 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
items = await asyncio.gather(*tasks)
|
||||
except NotFoundError as err:
|
||||
raise HomeAssistantError("Could not find To-do item") from err
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
|
||||
|
||||
# Run serially as some CalDAV servers do not support concurrent modifications
|
||||
for item in items:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(item.delete)
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV delete error: {err}") from err
|
||||
# refreshing async otherwise it would take too much time
|
||||
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.0.2",
|
||||
"aioesphomeapi==45.0.4",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
|
||||
@@ -61,14 +61,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
|
||||
except FRITZ_AUTH_EXCEPTIONS as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except FRITZ_EXCEPTIONS as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_connecting",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in avm_wrapper.connection.services
|
||||
and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed("Missing UPnP configuration")
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_upnp_disabled",
|
||||
)
|
||||
|
||||
await avm_wrapper.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -185,12 +185,18 @@
|
||||
"config_entry_not_found": {
|
||||
"message": "Failed to perform action \"{service}\". Config entry for target not found"
|
||||
},
|
||||
"error_connecting": {
|
||||
"message": "Error connecting to the FRITZ!Box: {error}"
|
||||
},
|
||||
"error_parse_device_info": {
|
||||
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
|
||||
},
|
||||
"error_refresh_hosts_info": {
|
||||
"message": "Error refreshing hosts info"
|
||||
},
|
||||
"error_upnp_disabled": {
|
||||
"message": "UPnP is disabled on the FRITZ!Box. Please enable UPnP to use this integration."
|
||||
},
|
||||
"service_dial_failed": {
|
||||
"message": "Failed to dial, check if the click to dial service of the FRITZ!Box is activated"
|
||||
},
|
||||
@@ -200,9 +206,6 @@
|
||||
"service_parameter_unknown": {
|
||||
"message": "Action or parameter unknown"
|
||||
},
|
||||
"unable_to_connect": {
|
||||
"message": "Unable to establish a connection"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Error while updating the data: {error}"
|
||||
}
|
||||
|
||||
@@ -806,10 +806,10 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
|
||||
data["daysOfMonth"] = [start_date.day]
|
||||
data["weeksOfMonth"] = []
|
||||
|
||||
if interval := call.data.get(ATTR_INTERVAL):
|
||||
if (interval := call.data.get(ATTR_INTERVAL)) is not None:
|
||||
data["everyX"] = interval
|
||||
|
||||
if streak := call.data.get(ATTR_STREAK):
|
||||
if (streak := call.data.get(ATTR_STREAK)) is not None:
|
||||
data["streak"] = streak
|
||||
|
||||
try:
|
||||
|
||||
@@ -52,9 +52,7 @@ reload_config_entry:
|
||||
target:
|
||||
fields:
|
||||
entry_id:
|
||||
advanced: true
|
||||
required: false
|
||||
example: 8955375327824e14ba89e4b29cc3ec9a
|
||||
selector:
|
||||
config_entry:
|
||||
|
||||
|
||||
@@ -223,10 +223,10 @@
|
||||
"name": "Reload all Home Assistant configuration"
|
||||
},
|
||||
"reload_config_entry": {
|
||||
"description": "Reloads the specified config entry.",
|
||||
"description": "Reloads any explicitly provided config entry ID and any config entries referenced by entities or devices in the target. If both are provided, the union of those config entries is reloaded.",
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"description": "The configuration entry ID of the entry to be reloaded.",
|
||||
"description": "Optional configuration entry ID to reload.",
|
||||
"name": "Config entry ID"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_COMMAND,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
@@ -39,9 +40,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_COMMAND = "command"
|
||||
|
||||
EVENT_BUTTON_PRESS = "homeworks_button_press"
|
||||
EVENT_BUTTON_RELEASE = "homeworks_button_release"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_MODEL
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_GENERATION, CONF_SERIAL_NUMBER, DEFAULT_PORT, DOMAIN
|
||||
|
||||
@@ -21,6 +22,12 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self._discovered_host: str | None = None
|
||||
self._discovered_device_data: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -83,6 +90,55 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery — probe the device to confirm it is an Indevolt device."""
|
||||
host = discovery_info.ip
|
||||
|
||||
try:
|
||||
device_data = await self._async_get_device_data(host)
|
||||
except OSError, ClientError, KeyError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER])
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: host}, reload_on_update=True
|
||||
)
|
||||
|
||||
self.context["title_placeholders"] = {"model": device_data[CONF_MODEL]}
|
||||
self._discovered_host = host
|
||||
self._discovered_device_data = device_data
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm DHCP discovery by user."""
|
||||
assert self._discovered_host is not None
|
||||
assert self._discovered_device_data is not None
|
||||
|
||||
# Attempt to setup from user input
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=f"INDEVOLT {self._discovered_device_data[CONF_MODEL]}",
|
||||
data={
|
||||
CONF_HOST: self._discovered_host,
|
||||
**self._discovered_device_data,
|
||||
},
|
||||
)
|
||||
|
||||
# Retrieve user confirmation
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={
|
||||
CONF_HOST: self._discovered_host,
|
||||
CONF_MODEL: self._discovered_device_data[CONF_MODEL],
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_validate_input(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[dict[str, str], dict[str, Any] | None]:
|
||||
|
||||
@@ -72,7 +72,11 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
config_data = await self.api.get_config()
|
||||
except (ClientError, OSError) as err:
|
||||
raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
# Cache device information
|
||||
device_data = config_data.get("device", {})
|
||||
@@ -87,7 +91,11 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
return await self.api.fetch_data(sensor_keys)
|
||||
except (ClientError, OSError) as err:
|
||||
raise UpdateFailed(f"Device update failed: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
|
||||
"""Push/write data values to given key on the device."""
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
"name": "Indevolt",
|
||||
"codeowners": ["@xirt"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{ "macaddress": "1C784B*" },
|
||||
{ "macaddress": "34EAE7*" },
|
||||
{ "macaddress": "7C3E82*" },
|
||||
{ "registered_devices": true }
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/indevolt",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -40,12 +40,8 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration does not support network discovery
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration does not support network discovery
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
@@ -60,7 +56,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to add {model} ({host}) to Home Assistant?",
|
||||
"title": "Discovered Indevolt {model}"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
@@ -369,6 +373,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_ready": {
|
||||
"message": "Device config retrieval failed: {error}"
|
||||
},
|
||||
"energy_mode_change_unavailable_outdoor_portable": {
|
||||
"message": "Energy mode cannot be changed when the device is in outdoor/portable mode"
|
||||
},
|
||||
@@ -396,6 +403,9 @@
|
||||
"soc_below_minimum": {
|
||||
"message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Device update failed: {error}"
|
||||
},
|
||||
"write_error": {
|
||||
"message": "Cannot update value for {name}"
|
||||
}
|
||||
|
||||
@@ -293,11 +293,11 @@ async def async_check_config_schema(
|
||||
)
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
message,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_platform_config",
|
||||
translation_key="invalid_platform_config_message",
|
||||
translation_placeholders={
|
||||
"domain": domain,
|
||||
"message": message,
|
||||
},
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -158,7 +158,6 @@ async def async_publish(
|
||||
if not mqtt_config_entry_enabled(hass):
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise HomeAssistantError(
|
||||
f"Cannot publish to topic '{topic}', MQTT is not enabled",
|
||||
translation_key="mqtt_not_setup_cannot_publish",
|
||||
translation_domain=DOMAIN,
|
||||
translation_placeholders={"topic": topic},
|
||||
@@ -284,7 +283,6 @@ def async_subscribe_internal(
|
||||
except KeyError as exc:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise HomeAssistantError(
|
||||
f"Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly",
|
||||
translation_key="mqtt_not_setup_cannot_subscribe",
|
||||
translation_domain=DOMAIN,
|
||||
translation_placeholders={"topic": topic},
|
||||
@@ -293,8 +291,7 @@ def async_subscribe_internal(
|
||||
if not mqtt_config_entry_enabled(hass):
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise HomeAssistantError(
|
||||
f"Cannot subscribe to topic '{topic}', MQTT is not enabled",
|
||||
translation_key="mqtt_not_setup_cannot_subscribe",
|
||||
translation_key="mqtt_not_enabled_cannot_subscribe",
|
||||
translation_domain=DOMAIN,
|
||||
translation_placeholders={"topic": topic},
|
||||
)
|
||||
|
||||
@@ -75,8 +75,6 @@ class SubscriptionID:
|
||||
if subscription_id > MAX_28BIT:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise HomeAssistantError(
|
||||
"MQTT Subscription ID limit reached. "
|
||||
"Cannot generate more IDs to subscribe",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mqtt_max_subscription_id_reached",
|
||||
)
|
||||
|
||||
@@ -1090,8 +1090,8 @@
|
||||
"command_template_error": {
|
||||
"message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}."
|
||||
},
|
||||
"invalid_platform_config": {
|
||||
"message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. See logs for more details."
|
||||
"invalid_platform_config_message": {
|
||||
"message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. Message: {message}"
|
||||
},
|
||||
"invalid_publish_topic": {
|
||||
"message": "Unable to publish: topic template `{topic_template}` produced an invalid topic `{topic}` after rendering ({error})"
|
||||
@@ -1105,6 +1105,9 @@
|
||||
"mqtt_message_expiry_interval_not_supported": {
|
||||
"message": "Publishing to topic {topic} with a Message Expiry Interval is not supported for protocol version {protocol}."
|
||||
},
|
||||
"mqtt_not_enabled_cannot_subscribe": {
|
||||
"message": "Cannot subscribe to topic \"{topic}\" because MQTT is not enabled, make sure MQTT is set up correctly."
|
||||
},
|
||||
"mqtt_not_setup_cannot_publish": {
|
||||
"message": "Cannot publish to topic \"{topic}\", make sure MQTT is set up correctly."
|
||||
},
|
||||
|
||||
@@ -162,7 +162,6 @@ class NFAndroidTVNotificationService(BaseNotificationService):
|
||||
else:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
"Invalid image provided",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_notification_image",
|
||||
translation_placeholders={"type": type(imagedata).__name__},
|
||||
@@ -185,7 +184,6 @@ class NFAndroidTVNotificationService(BaseNotificationService):
|
||||
else:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
"Invalid Icon provided",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_notification_icon",
|
||||
translation_placeholders={"type": type(icondata).__name__},
|
||||
|
||||
@@ -15,8 +15,6 @@ ATTR_REMOTE = "remote"
|
||||
|
||||
ATTR_DEVICE_INFO = "device_info"
|
||||
ATTR_FRIENDLY_NAME = "friendlyName"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MANUFACTURER = "manufacturer"
|
||||
ATTR_MODEL_NUMBER = "modelNumber"
|
||||
ATTR_UDN = "UDN"
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
|
||||
MediaType,
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import ATTR_MANUFACTURER, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -23,7 +23,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import PanasonicVieraConfigEntry
|
||||
from .const import (
|
||||
ATTR_DEVICE_INFO,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL_NUMBER,
|
||||
ATTR_UDN,
|
||||
DEFAULT_MANUFACTURER,
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.remote import RemoteEntity
|
||||
from homeassistant.const import CONF_NAME, STATE_ON
|
||||
from homeassistant.const import ATTR_MANUFACTURER, CONF_NAME, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import PanasonicVieraConfigEntry, Remote
|
||||
from .const import (
|
||||
ATTR_DEVICE_INFO,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL_NUMBER,
|
||||
ATTR_UDN,
|
||||
DEFAULT_MANUFACTURER,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/risco",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyrisco"],
|
||||
"requirements": ["pyrisco==0.6.8"]
|
||||
"requirements": ["pyrisco==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import hashlib
|
||||
@@ -38,6 +39,15 @@ from .utils import get_device_entry_gen
|
||||
CONTENT_TYPE_AUDIO = "audio"
|
||||
CONTENT_TYPE_RADIO = "radio"
|
||||
|
||||
ALLOWED_IMAGE_MIME_TYPES: Final = frozenset(
|
||||
{
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
}
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@@ -102,6 +112,9 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
|
||||
_last_media_position: int | None = None
|
||||
_last_media_position_updated_at: datetime.datetime | None = None
|
||||
|
||||
_cached_thumb: str | None = None
|
||||
_cached_thumb_result: tuple[bytes, str] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
@@ -215,9 +228,11 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_image_hash(self) -> str | None:
|
||||
"""Hash value for media image."""
|
||||
if (thumb := self._media_meta.get("thumb")) and thumb.startswith("data"):
|
||||
return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16]
|
||||
return super().media_image_hash
|
||||
thumb = self._media_meta.get("thumb")
|
||||
if not thumb or self._decode_image_data(thumb) is None:
|
||||
return super().media_image_hash
|
||||
|
||||
return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
def _get_updated_media_position(self) -> int | None:
|
||||
"""Return the current playback position and update its timestamp."""
|
||||
@@ -235,15 +250,11 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
|
||||
|
||||
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
|
||||
"""Fetch media image of current playing track."""
|
||||
thumb = self._media_meta["thumb"]
|
||||
try:
|
||||
prefix, image_data = thumb.split(",", 1)
|
||||
image = base64.b64decode(image_data, validate=True)
|
||||
mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1]
|
||||
except binascii.Error, ValueError:
|
||||
thumb = self._media_meta.get("thumb")
|
||||
if not thumb or (result := self._decode_image_data(thumb)) is None:
|
||||
return await super().async_get_media_image()
|
||||
|
||||
return image, mime
|
||||
return result
|
||||
|
||||
@rpc_call
|
||||
async def async_media_play(self) -> None:
|
||||
@@ -434,3 +445,25 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
|
||||
translation_key="unsupported_media_type",
|
||||
translation_placeholders={"media_type": str(media_type)},
|
||||
)
|
||||
|
||||
def _decode_image_data(self, thumb: str) -> tuple[bytes, str] | None:
|
||||
"""Return image_bytes and mime_type for a valid image data or None."""
|
||||
if thumb == self._cached_thumb:
|
||||
return self._cached_thumb_result
|
||||
|
||||
result: tuple[bytes, str] | None = None
|
||||
if thumb.startswith("data"):
|
||||
try:
|
||||
prefix, image_data = thumb.split(",", 1)
|
||||
mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1]
|
||||
except IndexError, ValueError:
|
||||
pass
|
||||
else:
|
||||
if mime in ALLOWED_IMAGE_MIME_TYPES:
|
||||
with contextlib.suppress(binascii.Error):
|
||||
result = base64.b64decode(image_data, validate=True), mime
|
||||
|
||||
self._cached_thumb = thumb
|
||||
self._cached_thumb_result = result
|
||||
|
||||
return result
|
||||
|
||||
@@ -69,18 +69,14 @@ class SMADataUpdateCoordinator(DataUpdateCoordinator[SMACoordinatorData]):
|
||||
SmaConnectionException,
|
||||
) as err:
|
||||
await self.async_close_sma_session()
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except SmaAuthenticationException as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def _async_update_data(self) -> SMACoordinatorData:
|
||||
@@ -91,18 +87,14 @@ class SMADataUpdateCoordinator(DataUpdateCoordinator[SMACoordinatorData]):
|
||||
SmaReadException,
|
||||
SmaConnectionException,
|
||||
) as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except SmaAuthenticationException as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
return SMACoordinatorData(
|
||||
|
||||
@@ -67,6 +67,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Could not connect to SMA device"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Invalid authentication for SMA device"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"group": {
|
||||
"options": {
|
||||
|
||||
@@ -394,7 +394,7 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
key=Attribute.COMPLETION_TIME,
|
||||
translation_key="completion_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
)
|
||||
],
|
||||
},
|
||||
@@ -447,7 +447,7 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
key=Attribute.COMPLETION_TIME,
|
||||
translation_key="completion_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
)
|
||||
],
|
||||
},
|
||||
@@ -565,7 +565,7 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
key=Attribute.GAS_METER_TIME,
|
||||
translation_key="gas_meter_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
)
|
||||
],
|
||||
Attribute.GAS_METER_VOLUME: [
|
||||
@@ -726,7 +726,7 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
key=Attribute.COMPLETION_TIME,
|
||||
translation_key="completion_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
component_fn=lambda component: component == "cavity-01",
|
||||
component_translation_key={
|
||||
"cavity-01": "oven_completion_time_cavity_01",
|
||||
@@ -1196,7 +1196,7 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
key=Attribute.COMPLETION_TIME,
|
||||
translation_key="completion_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
|
||||
component_fn=lambda component: component == "sub",
|
||||
component_translation_key={
|
||||
"sub": "washer_sub_completion_time",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"config_entry_not_ready": {
|
||||
"message": "Error while loading the config entry."
|
||||
},
|
||||
"update_error": {
|
||||
"update_failed": {
|
||||
"message": "Error while updating data from the API."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import MAX_LENGTH_STATE_STATE
|
||||
from homeassistant.const import ATTR_MODE, MAX_LENGTH_STATE_STATE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
@@ -23,7 +23,6 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from .const import (
|
||||
ATTR_MAX,
|
||||
ATTR_MIN,
|
||||
ATTR_MODE,
|
||||
ATTR_PATTERN,
|
||||
ATTR_VALUE,
|
||||
DOMAIN,
|
||||
|
||||
@@ -4,8 +4,6 @@ DOMAIN = "text"
|
||||
|
||||
ATTR_MAX = "max"
|
||||
ATTR_MIN = "min"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODE = "mode"
|
||||
ATTR_PATTERN = "pattern"
|
||||
ATTR_VALUE = "value"
|
||||
|
||||
|
||||
@@ -51,14 +51,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry)
|
||||
try:
|
||||
await client.authenticate()
|
||||
except ApiAuthError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"host": entry.data[CONF_HOST]},
|
||||
) from err
|
||||
except ApiConnectionError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"host": entry.data[CONF_HOST]},
|
||||
) from err
|
||||
|
||||
coordinator = UnifiAccessCoordinator(hass, entry, client)
|
||||
|
||||
@@ -197,17 +197,25 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
|
||||
self.client.get_emergency_status(),
|
||||
)
|
||||
except ApiAuthError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_auth",
|
||||
) from err
|
||||
except ApiConnectionError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed(f"Error connecting to API: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_connection",
|
||||
) from err
|
||||
except ApiError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_api",
|
||||
) from err
|
||||
except TimeoutError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed("Timeout communicating with UniFi Access API") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_timeout",
|
||||
) from err
|
||||
|
||||
previous_lock_rules = self.data.door_lock_rules.copy() if self.data else {}
|
||||
door_lock_rules: dict[str, DoorLockRuleStatus] = {}
|
||||
|
||||
@@ -133,6 +133,12 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "Authentication failed for UniFi Access at {host}."
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Unable to connect to UniFi Access at {host}."
|
||||
},
|
||||
"emergency_failed": {
|
||||
"message": "Failed to set emergency status."
|
||||
},
|
||||
@@ -150,6 +156,18 @@
|
||||
},
|
||||
"unlock_failed": {
|
||||
"message": "Failed to unlock the door."
|
||||
},
|
||||
"update_failed_api": {
|
||||
"message": "Error communicating with the UniFi Access API."
|
||||
},
|
||||
"update_failed_auth": {
|
||||
"message": "Authentication failed while updating data."
|
||||
},
|
||||
"update_failed_connection": {
|
||||
"message": "Error connecting to the UniFi Access API."
|
||||
},
|
||||
"update_failed_timeout": {
|
||||
"message": "Timeout communicating with the UniFi Access API."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -84,8 +84,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
|
||||
except NotAuthorized as err:
|
||||
data_service.auth_retries += 1
|
||||
if data_service.auth_retries > AUTH_RETRIES:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_auth_failed",
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except (TimeoutError, ClientError, ServerDisconnectedError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
@@ -105,12 +105,14 @@ def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]:
|
||||
|
||||
@callback
|
||||
def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn:
|
||||
msg = f"Unexpected identifier: {identifier}"
|
||||
exc = BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_identifier",
|
||||
translation_placeholders={"identifier": identifier},
|
||||
)
|
||||
if err is None:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BrowseError(msg)
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BrowseError(msg) from err
|
||||
raise exc
|
||||
raise exc from err
|
||||
|
||||
|
||||
@callback
|
||||
@@ -379,8 +381,10 @@ class ProtectMediaSource(MediaSource):
|
||||
_bad_identifier(f"{data.api.bootstrap.nvr.id}:{subtype}:{event_id}", err)
|
||||
|
||||
if event.start is None or event.end is None:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BrowseError("Event is still ongoing")
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="event_ongoing",
|
||||
)
|
||||
|
||||
return await self._build_event(data, event, thumbnail_only)
|
||||
|
||||
@@ -790,8 +794,11 @@ class ProtectMediaSource(MediaSource):
|
||||
if camera_id != "all":
|
||||
camera = data.api.bootstrap.cameras.get(camera_id)
|
||||
if camera is None:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BrowseError(f"Unknown Camera ID: {camera_id}")
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_camera_id",
|
||||
translation_placeholders={"camera_id": camera_id},
|
||||
)
|
||||
name = camera.name or camera.market_name or camera.type
|
||||
is_doorbell = camera.feature_flags.is_doorbell
|
||||
has_smart = camera.feature_flags.has_smart_detect
|
||||
|
||||
@@ -679,6 +679,12 @@
|
||||
"device_not_found": {
|
||||
"message": "No device found for device id: {device_id}"
|
||||
},
|
||||
"entry_auth_failed": {
|
||||
"message": "Authentication failed, please reauthenticate"
|
||||
},
|
||||
"event_ongoing": {
|
||||
"message": "Event is still ongoing"
|
||||
},
|
||||
"global_alarm_manager": {
|
||||
"message": "The alarm manager on this UniFi Protect NVR is set to Global mode and cannot be controlled locally."
|
||||
},
|
||||
@@ -717,6 +723,12 @@
|
||||
},
|
||||
"stream_error": {
|
||||
"message": "Error playing audio, check the logs for more details"
|
||||
},
|
||||
"unexpected_identifier": {
|
||||
"message": "Unexpected identifier: {identifier}"
|
||||
},
|
||||
"unknown_camera_id": {
|
||||
"message": "Unknown camera ID: {camera_id}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_access_or_create_backup_path": {
|
||||
"message": "Cannot access or create backup path"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to WebDAV server"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["wled==0.22.0"],
|
||||
"requirements": ["wled==0.23.0"],
|
||||
"zeroconf": ["_wled._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Support for Wyoming intent recognition services."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -7,7 +8,7 @@ from wyoming.asr import Transcript
|
||||
from wyoming.client import AsyncTcpClient
|
||||
from wyoming.handle import Handled, NotHandled
|
||||
from wyoming.info import HandleProgram, IntentProgram
|
||||
from wyoming.intent import Intent, NotRecognized
|
||||
from wyoming.intent import Intent, IntentsStart, IntentsStop, NotRecognized
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.const import MATCH_ALL
|
||||
@@ -86,6 +87,10 @@ class WyomingConversationEntity(
|
||||
model_languages.update(handle_model.languages)
|
||||
|
||||
self._attr_name = self._handle_service.name
|
||||
if self._handle_service.supports_home_control:
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
|
||||
self._supported_languages = list(model_languages)
|
||||
self._attr_unique_id = f"{config_entry.entry_id}-conversation"
|
||||
@@ -165,62 +170,27 @@ class WyomingConversationEntity(
|
||||
intent_response: intent.IntentResponse,
|
||||
) -> intent.IntentResponse:
|
||||
"""Process a sentence into an intent response."""
|
||||
has_intents_list = False
|
||||
intents: list[Intent] = []
|
||||
|
||||
while True:
|
||||
event = await client.read_event()
|
||||
if event is None:
|
||||
raise WyomingError("Connection lost")
|
||||
|
||||
if IntentsStart.is_type(event.type):
|
||||
# Multiple intents may be present
|
||||
has_intents_list = True
|
||||
continue
|
||||
|
||||
if Intent.is_type(event.type):
|
||||
# Success
|
||||
recognized_intent = Intent.from_event(event)
|
||||
_LOGGER.debug("Recognized intent: %s", recognized_intent)
|
||||
|
||||
intent_type = recognized_intent.name
|
||||
intent_slots = {
|
||||
e.name: {"value": e.value} for e in recognized_intent.entities
|
||||
}
|
||||
|
||||
# Add to trace and chat log
|
||||
conversation.async_conversation_trace_append(
|
||||
conversation.ConversationTraceEventType.TOOL_CALL,
|
||||
{
|
||||
"intent_name": intent_type,
|
||||
"slots": intent_slots,
|
||||
},
|
||||
)
|
||||
tool_input = llm.ToolInput(
|
||||
tool_name=intent_type,
|
||||
tool_args=intent_slots,
|
||||
external=True,
|
||||
)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=None,
|
||||
tool_calls=[tool_input],
|
||||
)
|
||||
)
|
||||
intent_response = await intent.async_handle(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
intent_type,
|
||||
intent_slots,
|
||||
text_input=user_input.text,
|
||||
language=user_input.language,
|
||||
satellite_id=user_input.satellite_id,
|
||||
device_id=user_input.device_id,
|
||||
)
|
||||
|
||||
if (not intent_response.speech) and recognized_intent.text:
|
||||
response_text = recognized_intent.text
|
||||
if template.is_template_string(response_text):
|
||||
# Render text as a template
|
||||
response_text = self._render_speech_template(
|
||||
response_text, intent_response, intent_slots
|
||||
)
|
||||
|
||||
intent_response.async_set_speech(response_text)
|
||||
intents.append(Intent.from_event(event))
|
||||
if not has_intents_list:
|
||||
# Only one intent, no need to wait
|
||||
break
|
||||
|
||||
if IntentsStop.is_type(event.type):
|
||||
# End of intents list
|
||||
break
|
||||
|
||||
if NotRecognized.is_type(event.type):
|
||||
@@ -230,6 +200,9 @@ class WyomingConversationEntity(
|
||||
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
|
||||
not_recognized.text or "",
|
||||
)
|
||||
|
||||
# Don't process any intents if one was not recognized
|
||||
intents.clear()
|
||||
break
|
||||
|
||||
if Handled.is_type(event.type):
|
||||
@@ -247,6 +220,107 @@ class WyomingConversationEntity(
|
||||
)
|
||||
break
|
||||
|
||||
if not intents:
|
||||
return intent_response
|
||||
|
||||
# Process recognized intents with a task group.
|
||||
# If any intent fails to be handled, the rest are cancelled.
|
||||
intent_responses: list[intent.IntentResponse] = []
|
||||
try:
|
||||
async with asyncio.TaskGroup() as task_group:
|
||||
intent_tasks: list[tuple[str, dict, str | None, asyncio.Task]] = []
|
||||
for recognized_intent in intents:
|
||||
_LOGGER.debug("Handling intent: %s", recognized_intent)
|
||||
|
||||
intent_type = recognized_intent.name
|
||||
intent_slots = {
|
||||
e.name: {"value": e.value} for e in recognized_intent.entities
|
||||
}
|
||||
|
||||
# Add to trace
|
||||
conversation.async_conversation_trace_append(
|
||||
conversation.ConversationTraceEventType.TOOL_CALL,
|
||||
{
|
||||
"intent_name": intent_type,
|
||||
"slots": intent_slots,
|
||||
},
|
||||
)
|
||||
intent_tasks.append(
|
||||
(
|
||||
intent_type,
|
||||
intent_slots,
|
||||
recognized_intent.text,
|
||||
task_group.create_task(
|
||||
intent.async_handle(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
intent_type,
|
||||
intent_slots,
|
||||
text_input=user_input.text,
|
||||
language=user_input.language,
|
||||
satellite_id=user_input.satellite_id,
|
||||
device_id=user_input.device_id,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
except* intent.IntentError as err_group:
|
||||
# Bubble up first exception only.
|
||||
# There's nothing the caller can do with multiple intent errors.
|
||||
raise err_group.exceptions[0] from err_group
|
||||
|
||||
# Gather intent handling results
|
||||
tool_calls: list[llm.ToolInput] = []
|
||||
for intent_type, intent_slots, intent_text, intent_task in intent_tasks:
|
||||
intent_task_response = await intent_task
|
||||
intent_responses.append(intent_task_response)
|
||||
|
||||
# For the chat log
|
||||
tool_calls.append(
|
||||
llm.ToolInput(
|
||||
tool_name=intent_type,
|
||||
tool_args=intent_slots,
|
||||
external=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Process speech
|
||||
if (not intent_task_response.speech) and intent_text:
|
||||
if template.is_template_string(intent_text):
|
||||
# Render text as a template
|
||||
intent_text = self._render_speech_template(
|
||||
intent_text, intent_task_response, intent_slots
|
||||
)
|
||||
|
||||
intent_task_response.async_set_speech(intent_text)
|
||||
|
||||
# Add all tool calls to the chat log
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=None,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
)
|
||||
|
||||
# Must be the case because an exception would have been thrown otherwise
|
||||
assert intent_responses
|
||||
|
||||
# Use the properties of the first intent (response_type, etc.) and
|
||||
# combine the speech results.
|
||||
intent_response = intent_responses[0]
|
||||
speech_texts: list[str] = [
|
||||
speech
|
||||
for current_response in intent_responses
|
||||
if (speech := current_response.speech.get("plain", {}).get("speech"))
|
||||
]
|
||||
|
||||
if speech_texts:
|
||||
# Combine response with newlines because punctuation would be
|
||||
# language-dependent.
|
||||
intent_response.async_set_speech("\n".join(speech_texts))
|
||||
|
||||
return intent_response
|
||||
|
||||
def _render_speech_template(
|
||||
|
||||
Generated
+16
@@ -349,6 +349,22 @@ DHCP: Final[list[dict[str, str | bool]]] = [
|
||||
"domain": "incomfort",
|
||||
"registered_devices": True,
|
||||
},
|
||||
{
|
||||
"domain": "indevolt",
|
||||
"macaddress": "1C784B*",
|
||||
},
|
||||
{
|
||||
"domain": "indevolt",
|
||||
"macaddress": "34EAE7*",
|
||||
},
|
||||
{
|
||||
"domain": "indevolt",
|
||||
"macaddress": "7C3E82*",
|
||||
},
|
||||
{
|
||||
"domain": "indevolt",
|
||||
"registered_devices": True,
|
||||
},
|
||||
{
|
||||
"domain": "insteon",
|
||||
"macaddress": "000EF3*",
|
||||
|
||||
Generated
+4
-4
@@ -254,7 +254,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==45.0.2
|
||||
aioesphomeapi==45.0.4
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -1890,7 +1890,7 @@ pushover_complete==1.2.0
|
||||
pvo==3.0.0
|
||||
|
||||
# homeassistant.components.aosmith
|
||||
py-aosmith==1.0.17
|
||||
py-aosmith==1.0.18
|
||||
|
||||
# homeassistant.components.canary
|
||||
py-canary==0.5.4
|
||||
@@ -2479,7 +2479,7 @@ pyrecswitch==1.0.2
|
||||
pyrepetierng==0.1.0
|
||||
|
||||
# homeassistant.components.risco
|
||||
pyrisco==0.6.8
|
||||
pyrisco==0.7.0
|
||||
|
||||
# homeassistant.components.rituals_perfume_genie
|
||||
pyrituals==0.0.7
|
||||
@@ -3356,7 +3356,7 @@ wiim==0.1.2
|
||||
wirelesstagpy==0.8.1
|
||||
|
||||
# homeassistant.components.wled
|
||||
wled==0.22.0
|
||||
wled==0.23.0
|
||||
|
||||
# homeassistant.components.wolflink
|
||||
wolf-comm==0.0.48
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -9,7 +9,8 @@ cd "$(realpath "$(dirname "$0")/..")"
|
||||
echo "Installing development dependencies..."
|
||||
uv pip install \
|
||||
-e . \
|
||||
-r requirements_test_all.txt \
|
||||
-r requirements_all.txt \
|
||||
-r requirements_test.txt \
|
||||
colorlog \
|
||||
--upgrade \
|
||||
--config-settings editable_mode=compat
|
||||
|
||||
@@ -26,6 +26,7 @@ class CheckKind(StrEnum):
|
||||
CI_UPLOAD = "ci_upload"
|
||||
RELEASE_PIPELINE = "release_pipeline"
|
||||
PR_LINK = "pr_link"
|
||||
ASYNC_BLOCKING = "async_blocking"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -20,6 +20,7 @@ _CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
|
||||
(CheckKind.CI_UPLOAD, "CI Upload"),
|
||||
(CheckKind.RELEASE_PIPELINE, "Release Pipeline"),
|
||||
(CheckKind.PR_LINK, "PR Link"),
|
||||
(CheckKind.ASYNC_BLOCKING, "Async Safe"),
|
||||
)
|
||||
|
||||
_ICONS: dict[CheckStatus, str] = {
|
||||
|
||||
@@ -10,6 +10,9 @@ What the runner defers to the LLM (NEEDS_AGENT):
|
||||
- `pr_link`: presence of the right link in the PR description.
|
||||
- `release_pipeline`: inspection of the publish workflow when the attestation
|
||||
was missing or did not identify a recognised CI publisher.
|
||||
- `async_blocking`: inspection of the dependency source for blocking I/O
|
||||
inside `async def` functions. Always deferred when the source repo is
|
||||
available — the deterministic stage cannot read the upstream source.
|
||||
"""
|
||||
|
||||
from .diff import parse_diff
|
||||
@@ -74,6 +77,7 @@ def run_checks(
|
||||
)
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = fail
|
||||
pkg.checks[CheckKind.PR_LINK] = fail
|
||||
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
|
||||
elif pkg.repo_url:
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = CheckResult(
|
||||
CheckStatus.NEEDS_AGENT,
|
||||
@@ -83,6 +87,20 @@ def run_checks(
|
||||
CheckStatus.NEEDS_AGENT,
|
||||
"Presence of the required link in the PR description must be verified by the agent.",
|
||||
)
|
||||
if pkg.old_version is None:
|
||||
async_reason = (
|
||||
"New dependency: agent must review the entire source tree "
|
||||
"at the new version for blocking I/O inside async functions."
|
||||
)
|
||||
else:
|
||||
async_reason = (
|
||||
f"Version bump {pkg.old_version} → {pkg.new_version}: "
|
||||
"agent must review only the diff for newly introduced "
|
||||
"blocking I/O inside async functions."
|
||||
)
|
||||
pkg.checks[CheckKind.ASYNC_BLOCKING] = CheckResult(
|
||||
CheckStatus.NEEDS_AGENT, async_reason
|
||||
)
|
||||
else:
|
||||
fail = CheckResult(
|
||||
CheckStatus.FAIL,
|
||||
@@ -90,6 +108,7 @@ def run_checks(
|
||||
)
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = fail
|
||||
pkg.checks[CheckKind.PR_LINK] = fail
|
||||
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
|
||||
result = CheckRunResult(pr_number=pr_number, packages=packages)
|
||||
result.rendered_comment = render_comment(result)
|
||||
return result
|
||||
|
||||
@@ -262,19 +262,6 @@ IGNORE_PRE_COMMIT_HOOK_ID = (
|
||||
PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$")
|
||||
|
||||
|
||||
def has_tests(module: str) -> bool:
|
||||
"""Test if a module has tests.
|
||||
|
||||
Module format: homeassistant.components.hue
|
||||
Test if exists: tests/components/hue/__init__.py
|
||||
"""
|
||||
path = (
|
||||
Path(module.replace(".", "/").replace("homeassistant", "tests", 1))
|
||||
/ "__init__.py"
|
||||
)
|
||||
return path.exists()
|
||||
|
||||
|
||||
def explore_module(package: str, explore_children: bool) -> list[str]:
|
||||
"""Explore the modules."""
|
||||
module = importlib.import_module(package)
|
||||
@@ -511,31 +498,6 @@ def requirements_all_action_output(reqs: dict[str, list[str]], action: str) -> s
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def requirements_test_all_output(reqs: dict[str, list[str]]) -> str:
|
||||
"""Generate output for test_requirements."""
|
||||
output = [
|
||||
"# Home Assistant tests, full dependency set\n",
|
||||
GENERATED_MESSAGE,
|
||||
"-r requirements_test.txt\n",
|
||||
]
|
||||
|
||||
filtered = {
|
||||
requirement: modules
|
||||
for requirement, modules in reqs.items()
|
||||
if any(
|
||||
# Always install requirements that are not part of integrations
|
||||
not mdl.startswith("homeassistant.components.")
|
||||
or
|
||||
# Install tests for integrations that have tests
|
||||
has_tests(mdl)
|
||||
for mdl in modules
|
||||
)
|
||||
}
|
||||
output.append(generate_requirements_list(filtered))
|
||||
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def requirements_pre_commit_output() -> str:
|
||||
"""Generate output for pre-commit dependencies."""
|
||||
source = ".pre-commit-config.yaml"
|
||||
@@ -609,7 +571,6 @@ def main(validate: bool, ci: bool) -> int:
|
||||
action: requirements_all_action_output(data, action)
|
||||
for action in OVERRIDDEN_REQUIREMENTS_ACTIONS
|
||||
}
|
||||
reqs_test_all_file = requirements_test_all_output(data)
|
||||
# Always calling requirements_pre_commit_output is intentional to ensure
|
||||
# the code is called by the pre-commit hooks.
|
||||
reqs_pre_commit_file = requirements_pre_commit_output()
|
||||
@@ -619,7 +580,6 @@ def main(validate: bool, ci: bool) -> int:
|
||||
("requirements.txt", reqs_file),
|
||||
("requirements_all.txt", reqs_all_file),
|
||||
("requirements_test_pre_commit.txt", reqs_pre_commit_file),
|
||||
("requirements_test_all.txt", reqs_test_all_file),
|
||||
("homeassistant/package_constraints.txt", constraints),
|
||||
]
|
||||
if ci:
|
||||
|
||||
@@ -233,7 +233,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
|
||||
},
|
||||
"velbus": {"velbus-aio": {"backoff"}},
|
||||
"volkszaehler": {"volkszaehler": {"async-timeout"}},
|
||||
"wled": {"wled": {"backoff"}},
|
||||
"whirlpool": {"whirlpool-sixth-sense": {"async-timeout"}},
|
||||
"zamg": {"zamg": {"async-timeout"}},
|
||||
"zha": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import replace
|
||||
import socket
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from arcam.fmj.client import ConnectionFailed
|
||||
@@ -111,21 +112,29 @@ async def test_ssdp_abort(hass: HomeAssistant) -> None:
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"connect_exception",
|
||||
[
|
||||
pytest.param(ConnectionFailed, id="connection_failed"),
|
||||
pytest.param(ConnectionRefusedError, id="connection_refused"),
|
||||
pytest.param(OSError, id="os_error"),
|
||||
pytest.param(socket.gaierror, id="gaierror"),
|
||||
pytest.param(TimeoutError, id="timeout"),
|
||||
],
|
||||
)
|
||||
async def test_ssdp_unable_to_connect(
|
||||
hass: HomeAssistant, dummy_client: MagicMock
|
||||
hass: HomeAssistant,
|
||||
dummy_client: MagicMock,
|
||||
connect_exception: type[Exception],
|
||||
) -> None:
|
||||
"""Test a ssdp import flow."""
|
||||
dummy_client.start.side_effect = AsyncMock(side_effect=ConnectionFailed)
|
||||
"""Test a ssdp import flow aborts when the device is unreachable."""
|
||||
dummy_client.start.side_effect = AsyncMock(side_effect=connect_exception)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={CONF_SOURCE: SOURCE_SSDP},
|
||||
data=MOCK_DISCOVER,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
@@ -232,3 +241,49 @@ async def test_user_wrong(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"Arcam FMJ ({MOCK_HOST})"
|
||||
assert result["result"].unique_id is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("connect_exception", "expected_error"),
|
||||
[
|
||||
pytest.param(ConnectionFailed, "cannot_connect", id="connection_failed"),
|
||||
pytest.param(
|
||||
ConnectionRefusedError, "connection_refused", id="connection_refused"
|
||||
),
|
||||
pytest.param(OSError, "cannot_connect", id="os_error"),
|
||||
pytest.param(socket.gaierror, "invalid_host", id="invalid_host"),
|
||||
pytest.param(TimeoutError, "timeout_connect", id="timeout_connect"),
|
||||
],
|
||||
)
|
||||
async def test_user_unable_to_connect(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
dummy_client: MagicMock,
|
||||
connect_exception: type[Exception],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test a manual user configuration flow where the device cannot be reached."""
|
||||
dummy_client.start.side_effect = AsyncMock(side_effect=connect_exception)
|
||||
aioclient_mock.get(MOCK_UPNP_LOCATION, status=404)
|
||||
|
||||
user_input = {
|
||||
CONF_HOST: MOCK_HOST,
|
||||
CONF_PORT: MOCK_PORT,
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={CONF_SOURCE: SOURCE_USER},
|
||||
data=user_input,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
dummy_client.start.side_effect = AsyncMock(return_value=None)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"Arcam FMJ ({MOCK_HOST})"
|
||||
assert result["data"] == MOCK_CONFIG_ENTRY
|
||||
|
||||
@@ -39,6 +39,7 @@ async def test_load_unload(
|
||||
[
|
||||
(Exception(), ConfigEntryState.SETUP_ERROR, []),
|
||||
(requests.ConnectionError(), ConfigEntryState.SETUP_RETRY, []),
|
||||
(requests.Timeout(), ConfigEntryState.SETUP_RETRY, []),
|
||||
(DAVError(), ConfigEntryState.SETUP_RETRY, []),
|
||||
(
|
||||
AuthorizationError(reason="Unauthorized"),
|
||||
|
||||
@@ -115,6 +115,8 @@ async def test_setup_fail(hass: HomeAssistant, error) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert entry.state.recoverable is True
|
||||
assert entry.error_reason_translation_key == "error_connecting"
|
||||
|
||||
|
||||
async def test_setup_fail_parse_error(hass: HomeAssistant, fc_class_mock) -> None:
|
||||
@@ -138,7 +140,6 @@ async def test_setup_fail_parse_error(hass: HomeAssistant, fc_class_mock) -> Non
|
||||
|
||||
async def test_upnp_missing(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
fc_class_mock,
|
||||
fh_class_mock,
|
||||
fs_class_mock,
|
||||
@@ -157,12 +158,9 @@ async def test_upnp_missing(
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert entry.state.recoverable is True
|
||||
assert (
|
||||
"Config entry 'Mock Title' for fritz integration"
|
||||
" could not authenticate: Missing UPnP configuration" in caplog.text
|
||||
)
|
||||
assert entry.error_reason_translation_key == "error_upnp_disabled"
|
||||
|
||||
|
||||
async def test_execute_action_while_shutdown(
|
||||
|
||||
@@ -1774,6 +1774,12 @@ async def test_create_todo(
|
||||
},
|
||||
Task(everyX=5),
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_INTERVAL: 0,
|
||||
},
|
||||
Task(everyX=0),
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_FREQUENCY: "weekly",
|
||||
|
||||
@@ -15,8 +15,9 @@ from homeassistant.const import CONF_HOST, CONF_MODEL
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
|
||||
TEST_HOST = "192.168.1.100"
|
||||
ALT_TEST_HOST = "192.168.1.101"
|
||||
TEST_HOST_ALT = "192.168.1.101"
|
||||
TEST_PORT = 8080
|
||||
|
||||
TEST_DEVICE_SN_GEN1 = "BK1600-12345678"
|
||||
TEST_DEVICE_SN_GEN2 = "SolidFlex2000-87654321"
|
||||
TEST_MODEL_GEN1 = "BK1600"
|
||||
@@ -28,7 +29,7 @@ DEVICE_MAPPING = {
|
||||
"device": TEST_MODEL_GEN1,
|
||||
"generation": 1,
|
||||
"sn": TEST_DEVICE_SN_GEN1,
|
||||
"host": ALT_TEST_HOST,
|
||||
"host": TEST_HOST_ALT,
|
||||
"mac": "aa:bb:cc:11:22:33",
|
||||
"fw": "1.2.3",
|
||||
},
|
||||
|
||||
@@ -10,18 +10,16 @@ from homeassistant.components.indevolt.const import (
|
||||
CONF_SERIAL_NUMBER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
|
||||
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .conftest import TEST_DEVICE_SN_GEN2, TEST_HOST
|
||||
from .conftest import TEST_DEVICE_SN_GEN2, TEST_HOST, TEST_HOST_ALT, TEST_MODEL_GEN2
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
# Used to mock host change
|
||||
TEST_HOST_NEW = "192.168.1.200"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_user_flow_success(hass: HomeAssistant, mock_indevolt: AsyncMock) -> None:
|
||||
@@ -43,11 +41,11 @@ async def test_user_flow_success(hass: HomeAssistant, mock_indevolt: AsyncMock)
|
||||
|
||||
# Verify entry is created with correct data
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "INDEVOLT CMS-SF2000"
|
||||
assert result["title"] == f"INDEVOLT {TEST_MODEL_GEN2}"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: TEST_HOST,
|
||||
CONF_SERIAL_NUMBER: TEST_DEVICE_SN_GEN2,
|
||||
CONF_MODEL: "CMS-SF2000",
|
||||
CONF_MODEL: TEST_MODEL_GEN2,
|
||||
CONF_GENERATION: 2,
|
||||
}
|
||||
assert result["result"].unique_id == TEST_DEVICE_SN_GEN2
|
||||
@@ -94,7 +92,7 @@ async def test_user_flow_error(
|
||||
|
||||
# Verify entry is created with correct data
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "INDEVOLT CMS-SF2000"
|
||||
assert result["title"] == f"INDEVOLT {TEST_MODEL_GEN2}"
|
||||
|
||||
|
||||
async def test_user_flow_duplicate_entry(
|
||||
@@ -135,10 +133,8 @@ async def test_reconfigure_flow_success(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
# Mock new host input
|
||||
new_host = TEST_HOST_NEW
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: new_host}
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST_ALT}
|
||||
)
|
||||
|
||||
# Verify flow is aborted
|
||||
@@ -149,7 +145,7 @@ async def test_reconfigure_flow_success(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify entry is updated
|
||||
assert mock_config_entry.data[CONF_HOST] == new_host
|
||||
assert mock_config_entry.data[CONF_HOST] == TEST_HOST_ALT
|
||||
assert mock_config_entry.data[CONF_SERIAL_NUMBER] == TEST_DEVICE_SN_GEN2
|
||||
|
||||
|
||||
@@ -228,7 +224,7 @@ async def test_reconfigure_flow_different_device(
|
||||
|
||||
# Configure mock to cause host collision with different device
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST_NEW}
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST_ALT}
|
||||
)
|
||||
|
||||
# Verify flow is aborted with correct reason
|
||||
@@ -237,3 +233,140 @@ async def test_reconfigure_flow_different_device(
|
||||
|
||||
# Flush pending tasks
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_dhcp_flow_success(
|
||||
hass: HomeAssistant, mock_indevolt: AsyncMock, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test successful discovery flow."""
|
||||
# Verify confirmation form is returned with correct device info
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DhcpServiceInfo(
|
||||
ip=TEST_HOST,
|
||||
hostname="indevolt",
|
||||
macaddress="1c784b8d47bb",
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
assert result["description_placeholders"][CONF_HOST] == TEST_HOST
|
||||
assert result["description_placeholders"][CONF_MODEL] == TEST_MODEL_GEN2
|
||||
|
||||
# Verify entry is created with correct data
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"INDEVOLT {TEST_MODEL_GEN2}"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: TEST_HOST,
|
||||
CONF_SERIAL_NUMBER: TEST_DEVICE_SN_GEN2,
|
||||
CONF_MODEL: TEST_MODEL_GEN2,
|
||||
CONF_GENERATION: 2,
|
||||
}
|
||||
assert result["result"].unique_id == TEST_DEVICE_SN_GEN2
|
||||
|
||||
|
||||
async def test_dhcp_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_indevolt: AsyncMock
|
||||
) -> None:
|
||||
"""Test DHCP discovery aborts if already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Verify flow is aborted if device is already configured
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DhcpServiceInfo(
|
||||
ip=TEST_HOST,
|
||||
hostname="indevolt",
|
||||
macaddress="1c784b8d47bb",
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_dhcp_ip_change(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_indevolt: AsyncMock
|
||||
) -> None:
|
||||
"""Test DHCP discovery updates config entry host if the device moved to a new IP."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert mock_config_entry.data[CONF_HOST] == TEST_HOST
|
||||
|
||||
# Verify flow is aborted on ip change and existing entry host is updated
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DhcpServiceInfo(
|
||||
ip=TEST_HOST_ALT,
|
||||
hostname="indevolt",
|
||||
macaddress="1c784b8d47bb",
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert mock_config_entry.data[CONF_HOST] == TEST_HOST_ALT
|
||||
|
||||
|
||||
async def test_dhcp_ip_reuse_by_different_device(
|
||||
hass: HomeAssistant,
|
||||
alt_mock_config_entry: MockConfigEntry,
|
||||
mock_indevolt: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test DHCP discovery proceeds when the discovered IP is used by a different device."""
|
||||
alt_mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DhcpServiceInfo(
|
||||
ip=TEST_HOST_ALT,
|
||||
hostname="indevolt",
|
||||
macaddress="1c784b8d47bb",
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "reason"),
|
||||
[
|
||||
(TimeoutError, "cannot_connect"),
|
||||
(ConnectionError, "cannot_connect"),
|
||||
(ClientError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_dhcp_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
mock_indevolt: AsyncMock,
|
||||
exception: type[Exception],
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Test discovery aborts on connection errors."""
|
||||
|
||||
# Initiate discovery flow with exception
|
||||
mock_indevolt.get_config.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=DhcpServiceInfo(
|
||||
ip=TEST_HOST,
|
||||
hostname="indevolt",
|
||||
macaddress="1c784b8d47bb",
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == reason
|
||||
|
||||
@@ -7,7 +7,6 @@ import pytest
|
||||
|
||||
from homeassistant.components.panasonic_viera.const import (
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL_NUMBER,
|
||||
ATTR_UDN,
|
||||
CONF_APP_ID,
|
||||
@@ -19,7 +18,7 @@ from homeassistant.components.panasonic_viera.const import (
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
|
||||
from homeassistant.const import ATTR_MANUFACTURER, CONF_HOST, CONF_NAME, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for Shelly media player platform."""
|
||||
|
||||
from copy import deepcopy
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import Mock
|
||||
|
||||
from aioshelly.const import MODEL_WALL_DISPLAY
|
||||
@@ -372,7 +373,7 @@ async def test_get_image_http(
|
||||
|
||||
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert (state := hass.states.get(ENTITY_ID)) is not None
|
||||
assert "entity_picture_local" not in state.attributes
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
@@ -383,13 +384,54 @@ async def test_get_image_http(
|
||||
assert isinstance(content, bytes)
|
||||
|
||||
|
||||
async def test_get_image_http_base64_decode_error(
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_thumb",
|
||||
[
|
||||
"data:image/webp;base64,0",
|
||||
"data invalid",
|
||||
"data:video/mpg;base64,AAAA",
|
||||
],
|
||||
)
|
||||
async def test_get_image_http_stale_url_after_thumb_invalidated(
|
||||
hass: HomeAssistant,
|
||||
mock_rpc_device: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
invalid_thumb: str,
|
||||
) -> None:
|
||||
"""Test get image via http command base64 decode error."""
|
||||
"""Test image proxy with a stale URL after the thumb becomes invalid."""
|
||||
status = deepcopy(mock_rpc_device.status)
|
||||
status["media"] = STATUS_AUDIO_FILE
|
||||
monkeypatch.setattr(mock_rpc_device, "status", status)
|
||||
|
||||
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
|
||||
|
||||
assert (state := hass.states.get(ENTITY_ID)) is not None
|
||||
entity_picture = state.attributes["entity_picture"]
|
||||
|
||||
monkeypatch.setitem(
|
||||
mock_rpc_device.status["media"]["playback"]["media_meta"],
|
||||
"thumb",
|
||||
invalid_thumb,
|
||||
)
|
||||
mock_rpc_device.mock_update()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get(ENTITY_ID)) is not None
|
||||
assert "entity_picture" not in state.attributes
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(entity_picture)
|
||||
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
async def test_entity_picture_absent_base64_data_invalid(
|
||||
hass: HomeAssistant,
|
||||
mock_rpc_device: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test that entity_picture is absent when base64 data is invalid."""
|
||||
status = deepcopy(mock_rpc_device.status)
|
||||
status["media"] = STATUS_AUDIO_FILE
|
||||
status["media"]["playback"]["media_meta"]["thumb"] = "data:image/webp;base64,0"
|
||||
@@ -397,15 +439,64 @@ async def test_get_image_http_base64_decode_error(
|
||||
|
||||
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert "entity_picture_local" not in state.attributes
|
||||
assert (state := hass.states.get(ENTITY_ID)) is not None
|
||||
assert "entity_picture" not in state.attributes
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
client = await hass_client()
|
||||
resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}")
|
||||
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
resp = await client.get(state.attributes["entity_picture"])
|
||||
content = await resp.read()
|
||||
|
||||
assert isinstance(content, bytes)
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_thumb",
|
||||
[
|
||||
"data invalid",
|
||||
"lorem ipsum",
|
||||
],
|
||||
)
|
||||
async def test_entity_picture_absent_thumb_string_invalid(
|
||||
hass: HomeAssistant,
|
||||
mock_rpc_device: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
hass_client: ClientSessionGenerator,
|
||||
invalid_thumb: str,
|
||||
) -> None:
|
||||
"""Test that entity_picture is absent when thumb string has invalid format."""
|
||||
status = deepcopy(mock_rpc_device.status)
|
||||
status["media"] = STATUS_AUDIO_FILE
|
||||
status["media"]["playback"]["media_meta"]["thumb"] = invalid_thumb
|
||||
monkeypatch.setattr(mock_rpc_device, "status", status)
|
||||
|
||||
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
|
||||
|
||||
assert (state := hass.states.get(ENTITY_ID)) is not None
|
||||
assert "entity_picture" not in state.attributes
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}")
|
||||
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
async def test_entity_picture_absent_mime_type_not_allowed(
|
||||
hass: HomeAssistant,
|
||||
mock_rpc_device: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test that entity_picture is absent when MIME type is not allowed."""
|
||||
status = deepcopy(mock_rpc_device.status)
|
||||
status["media"] = STATUS_AUDIO_FILE
|
||||
status["media"]["playback"]["media_meta"]["thumb"] = "data:video/mpg;base64,0"
|
||||
monkeypatch.setattr(mock_rpc_device, "status", status)
|
||||
|
||||
await init_integration(hass, 2, model=MODEL_WALL_DISPLAY)
|
||||
|
||||
assert (state := hass.states.get(ENTITY_ID)) is not None
|
||||
assert "entity_picture" not in state.attributes
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}")
|
||||
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
async def test_rpc_media_player_browse_media_root(
|
||||
|
||||
@@ -5,11 +5,11 @@ import pytest
|
||||
from homeassistant.components.text.const import (
|
||||
ATTR_MAX,
|
||||
ATTR_MIN,
|
||||
ATTR_MODE,
|
||||
ATTR_PATTERN,
|
||||
DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
)
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.state import async_reproduce_state
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
'111': 'Chunchun',
|
||||
'112': 'Dancing Shadows',
|
||||
'113': 'Washing Machine',
|
||||
'114': 'RSVD',
|
||||
'115': 'Blends',
|
||||
'116': 'TV Simulator',
|
||||
'117': 'Dynamic Smooth',
|
||||
@@ -51,7 +50,6 @@
|
||||
'14': 'Theater Rainbow',
|
||||
'140': 'Waterfall',
|
||||
'141': 'Freqpixels',
|
||||
'142': 'RSVD',
|
||||
'143': 'Noisefire',
|
||||
'144': 'Puddlepeak',
|
||||
'145': 'Noisemove',
|
||||
@@ -61,7 +59,6 @@
|
||||
'149': 'Firenoise',
|
||||
'15': 'Running',
|
||||
'150': 'Squared Swirl',
|
||||
'151': 'RSVD',
|
||||
'152': 'DNA',
|
||||
'153': 'Matrix',
|
||||
'154': 'Metaballs',
|
||||
@@ -72,7 +69,6 @@
|
||||
'159': 'DJ Light',
|
||||
'16': 'Saw',
|
||||
'160': 'Funky Plank',
|
||||
'161': 'RSVD',
|
||||
'162': 'Pulser',
|
||||
'163': 'Blurz',
|
||||
'164': 'Drift',
|
||||
@@ -80,10 +76,7 @@
|
||||
'166': 'Sun Radiation',
|
||||
'167': 'Colored Bursts',
|
||||
'168': 'Julia',
|
||||
'169': 'RSVD',
|
||||
'17': 'Twinkle',
|
||||
'170': 'RSVD',
|
||||
'171': 'RSVD',
|
||||
'172': 'Game Of Life',
|
||||
'173': 'Tartan',
|
||||
'174': 'Polar Lights',
|
||||
@@ -138,7 +131,6 @@
|
||||
'50': 'Two Dots',
|
||||
'51': 'Fairytwinkle',
|
||||
'52': 'Running Dual',
|
||||
'53': 'RSVD',
|
||||
'54': 'Chase 3',
|
||||
'55': 'Tri Wipe',
|
||||
'56': 'Tri Fade',
|
||||
@@ -227,6 +219,7 @@
|
||||
'product': 'FOSS',
|
||||
'str': False,
|
||||
'udpport': 21324,
|
||||
'umpalcount': 0,
|
||||
'uptime': 966,
|
||||
'ver': '0.14.4',
|
||||
'vid': '2405180',
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -149,7 +148,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -158,7 +156,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -168,7 +165,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -176,9 +172,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -293,7 +286,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -382,7 +374,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -391,7 +382,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -401,7 +391,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -409,9 +398,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -520,7 +506,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -581,7 +566,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -609,7 +593,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -618,7 +601,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -628,7 +610,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -636,9 +617,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -749,7 +727,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -810,7 +787,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -838,7 +814,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -847,7 +822,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -857,7 +831,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -865,9 +838,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -1040,7 +1010,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -1101,7 +1070,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -1129,7 +1097,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -1138,7 +1105,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -1148,7 +1114,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -1156,9 +1121,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -1269,7 +1231,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -1330,7 +1291,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -1358,7 +1318,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -1367,7 +1326,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -1377,7 +1335,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -1385,9 +1342,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -1492,7 +1446,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -1553,7 +1506,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -1581,7 +1533,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -1590,7 +1541,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -1600,7 +1550,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -1608,9 +1557,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -1721,7 +1667,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -1782,7 +1727,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -1810,7 +1754,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -1819,7 +1762,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -1829,7 +1771,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -1837,9 +1778,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -1944,7 +1882,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -2005,7 +1942,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -2033,7 +1969,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -2042,7 +1977,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -2052,7 +1986,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -2060,9 +1993,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -2173,7 +2103,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -2234,7 +2163,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -2262,7 +2190,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -2271,7 +2198,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -2281,7 +2207,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -2289,9 +2214,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -2396,7 +2318,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -2457,7 +2378,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -2485,7 +2405,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -2494,7 +2413,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -2504,7 +2422,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -2512,9 +2429,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
@@ -2625,7 +2539,6 @@
|
||||
'Two Dots',
|
||||
'Fairytwinkle',
|
||||
'Running Dual',
|
||||
'RSVD',
|
||||
'Chase 3',
|
||||
'Tri Wipe',
|
||||
'Tri Fade',
|
||||
@@ -2686,7 +2599,6 @@
|
||||
'Chunchun',
|
||||
'Dancing Shadows',
|
||||
'Washing Machine',
|
||||
'RSVD',
|
||||
'Blends',
|
||||
'TV Simulator',
|
||||
'Dynamic Smooth',
|
||||
@@ -2714,7 +2626,6 @@
|
||||
'GEQ',
|
||||
'Waterfall',
|
||||
'Freqpixels',
|
||||
'RSVD',
|
||||
'Noisefire',
|
||||
'Puddlepeak',
|
||||
'Noisemove',
|
||||
@@ -2723,7 +2634,6 @@
|
||||
'Ripple Peak',
|
||||
'Firenoise',
|
||||
'Squared Swirl',
|
||||
'RSVD',
|
||||
'DNA',
|
||||
'Matrix',
|
||||
'Metaballs',
|
||||
@@ -2733,7 +2643,6 @@
|
||||
'Gravfreq',
|
||||
'DJ Light',
|
||||
'Funky Plank',
|
||||
'RSVD',
|
||||
'Pulser',
|
||||
'Blurz',
|
||||
'Drift',
|
||||
@@ -2741,9 +2650,6 @@
|
||||
'Sun Radiation',
|
||||
'Colored Bursts',
|
||||
'Julia',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'RSVD',
|
||||
'Game Of Life',
|
||||
'Tartan',
|
||||
'Polar Lights',
|
||||
|
||||
@@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion
|
||||
from wyoming.asr import Transcript
|
||||
from wyoming.handle import Handled, NotHandled
|
||||
from wyoming.info import Info
|
||||
from wyoming.intent import Entity, Intent, NotRecognized
|
||||
from wyoming.intent import Entity, Intent, IntentsStart, IntentsStop, NotRecognized
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.conversation import chat_log
|
||||
@@ -125,6 +125,99 @@ async def test_intent(
|
||||
}
|
||||
|
||||
|
||||
async def test_multiple_intents(
|
||||
hass: HomeAssistant,
|
||||
init_wyoming_intent: ConfigEntry,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
) -> None:
|
||||
"""Test when more than one intent is recognized."""
|
||||
agent_id = "conversation.test_intent"
|
||||
conversation_id = mock_chat_log.conversation_id
|
||||
satellite_id = "satellite-1234"
|
||||
device_id = "device-1234"
|
||||
|
||||
test_intent1 = Intent(
|
||||
name="TestIntent1",
|
||||
entities=[Entity(name="entity1", value="value1")],
|
||||
text="{{ slots.slot_name }}",
|
||||
)
|
||||
|
||||
test_intent2 = Intent(
|
||||
name="TestIntent2",
|
||||
entities=[Entity(name="entity2", value="value2")],
|
||||
text="{{ slots.slot_name }}",
|
||||
)
|
||||
|
||||
class TestIntentHandler(intent.IntentHandler):
|
||||
"""Test Intent Handler."""
|
||||
|
||||
def __init__(self, intent_type: str, slot_value: str) -> None:
|
||||
"""Initialize the handler."""
|
||||
self.intent_type = intent_type
|
||||
self._slot_value = slot_value
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent):
|
||||
"""Handle the intent."""
|
||||
response = intent_obj.create_response()
|
||||
response.async_set_speech_slots({"slot_name": self._slot_value})
|
||||
return response
|
||||
|
||||
intent.async_register(hass, TestIntentHandler("TestIntent1", "slot value 1"))
|
||||
intent.async_register(hass, TestIntentHandler("TestIntent2", "slot value 2"))
|
||||
|
||||
# Send multiple intent events framed by intents-start and intents-stop.
|
||||
client = MockAsyncTcpClient(
|
||||
[
|
||||
IntentsStart().event(),
|
||||
test_intent1.event(),
|
||||
test_intent2.event(),
|
||||
IntentsStop().event(),
|
||||
]
|
||||
)
|
||||
with patch(
|
||||
"homeassistant.components.wyoming.conversation.AsyncTcpClient",
|
||||
client,
|
||||
):
|
||||
result = await conversation.async_converse(
|
||||
hass=hass,
|
||||
text="test text",
|
||||
conversation_id=conversation_id,
|
||||
context=Context(),
|
||||
language=hass.config.language,
|
||||
agent_id=agent_id,
|
||||
satellite_id=satellite_id,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.speech, "No speech"
|
||||
|
||||
# Speech results are joined with newlines because punctuation would be
|
||||
# language-dependent.
|
||||
assert (
|
||||
result.response.speech.get("plain", {}).get("speech")
|
||||
== "slot value 1\nslot value 2"
|
||||
)
|
||||
|
||||
# Verify that chat log recorded all intents as tool calls
|
||||
content: chat_log.AssistantContent | None = next(
|
||||
filter(
|
||||
lambda c: isinstance(c, chat_log.AssistantContent), mock_chat_log.content
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert content is not None, "Missing assistant content"
|
||||
assert content.tool_calls and len(content.tool_calls) == 2
|
||||
|
||||
for tool_call, test_intent in zip(
|
||||
content.tool_calls, (test_intent1, test_intent2), strict=True
|
||||
):
|
||||
assert tool_call.tool_name == test_intent.name
|
||||
assert tool_call.tool_args == {
|
||||
e.name: {"value": e.value} for e in test_intent.entities
|
||||
}
|
||||
|
||||
|
||||
async def test_intent_handle_error(
|
||||
hass: HomeAssistant, init_wyoming_intent: ConfigEntry
|
||||
) -> None:
|
||||
@@ -161,6 +254,67 @@ async def test_intent_handle_error(
|
||||
assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE
|
||||
|
||||
|
||||
async def test_multiple_intents_handle_error(
|
||||
hass: HomeAssistant,
|
||||
init_wyoming_intent: ConfigEntry,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
) -> None:
|
||||
"""Test error during handling when multiple intents are recognized."""
|
||||
agent_id = "conversation.test_intent"
|
||||
|
||||
test_intent_1 = Intent(name="TestIntent1", entities=[], text="success")
|
||||
test_intent_2 = Intent(name="TestIntent2", entities=[], text="success")
|
||||
|
||||
class TestIntentHandler1(intent.IntentHandler):
|
||||
"""Test Intent Handler."""
|
||||
|
||||
intent_type = "TestIntent1"
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent):
|
||||
"""Handle the intent."""
|
||||
return intent_obj.create_response()
|
||||
|
||||
class TestIntentHandler2(intent.IntentHandler):
|
||||
"""Test Intent Handler."""
|
||||
|
||||
intent_type = "TestIntent2"
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent):
|
||||
"""Handle the intent."""
|
||||
raise intent.IntentError
|
||||
|
||||
intent.async_register(hass, TestIntentHandler1())
|
||||
intent.async_register(hass, TestIntentHandler2())
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.wyoming.conversation.AsyncTcpClient",
|
||||
MockAsyncTcpClient(
|
||||
[
|
||||
IntentsStart().event(),
|
||||
test_intent_1.event(),
|
||||
test_intent_2.event(),
|
||||
IntentsStop().event(),
|
||||
]
|
||||
),
|
||||
):
|
||||
result = await conversation.async_converse(
|
||||
hass=hass,
|
||||
text="test text",
|
||||
conversation_id=None,
|
||||
context=Context(),
|
||||
language=hass.config.language,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE
|
||||
|
||||
# Ensure that no tool calls were recorded
|
||||
assert not any(
|
||||
isinstance(c, chat_log.AssistantContent) for c in mock_chat_log.content
|
||||
)
|
||||
|
||||
|
||||
async def test_not_recognized(
|
||||
hass: HomeAssistant, init_wyoming_intent: ConfigEntry
|
||||
) -> None:
|
||||
@@ -343,3 +497,60 @@ async def test_supported_languages_empty_means_all(
|
||||
agent = conversation.async_get_agent(hass, agent_id)
|
||||
assert agent is not None
|
||||
assert agent.supported_languages == MATCH_ALL
|
||||
|
||||
|
||||
async def test_intent_supports_home_control(
|
||||
hass: HomeAssistant, intent_config_entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Test that the CONTROL supported feature is always set for intent services."""
|
||||
agent_id = "conversation.test_intent"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.wyoming.data.load_wyoming_info",
|
||||
return_value=Info(intent=INTENT_INFO.intent),
|
||||
):
|
||||
await hass.config_entries.async_setup(intent_config_entry.entry_id)
|
||||
|
||||
agent = conversation.async_get_agent(hass, agent_id)
|
||||
assert isinstance(agent, conversation.ConversationEntity)
|
||||
assert agent.supported_features is not None
|
||||
assert (
|
||||
agent.supported_features & conversation.ConversationEntityFeature.CONTROL
|
||||
) == conversation.ConversationEntityFeature.CONTROL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"supports_home_control",
|
||||
[False, True],
|
||||
)
|
||||
async def test_handle_supports_home_control(
|
||||
hass: HomeAssistant, intent_config_entry: ConfigEntry, supports_home_control: bool
|
||||
) -> None:
|
||||
"""Test that the CONTROL supported feature matches the Wyoming info."""
|
||||
agent_id = "conversation.test_handle"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
HANDLE_INFO.handle[0], "supports_home_control", supports_home_control
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.wyoming.data.load_wyoming_info",
|
||||
return_value=Info(handle=HANDLE_INFO.handle),
|
||||
),
|
||||
):
|
||||
await hass.config_entries.async_setup(intent_config_entry.entry_id)
|
||||
|
||||
agent = conversation.async_get_agent(hass, agent_id)
|
||||
assert isinstance(agent, conversation.ConversationEntity)
|
||||
supported_features = (
|
||||
agent.supported_features or conversation.ConversationEntityFeature(0)
|
||||
)
|
||||
|
||||
control_feature = (
|
||||
supported_features & conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
|
||||
if supports_home_control:
|
||||
assert control_feature == conversation.ConversationEntityFeature.CONTROL
|
||||
else:
|
||||
assert control_feature == conversation.ConversationEntityFeature(0)
|
||||
|
||||
@@ -26,6 +26,7 @@ def test_render_all_conclusive_collapses_details() -> None:
|
||||
CheckKind.CI_UPLOAD: _pass("attestation found"),
|
||||
CheckKind.RELEASE_PIPELINE: _pass("OIDC via attestation"),
|
||||
CheckKind.PR_LINK: _pass("link found"),
|
||||
CheckKind.ASYNC_BLOCKING: _pass("no blocking calls in async"),
|
||||
},
|
||||
)
|
||||
result = CheckRunResult(pr_number=1, packages=[pkg])
|
||||
@@ -49,6 +50,7 @@ def test_render_needs_agent_emits_generic_placeholders() -> None:
|
||||
CheckKind.CI_UPLOAD: CheckResult(CheckStatus.WARN, "no attestation"),
|
||||
CheckKind.RELEASE_PIPELINE: CheckResult(CheckStatus.NEEDS_AGENT, ""),
|
||||
CheckKind.PR_LINK: CheckResult(CheckStatus.NEEDS_AGENT, ""),
|
||||
CheckKind.ASYNC_BLOCKING: CheckResult(CheckStatus.NEEDS_AGENT, ""),
|
||||
},
|
||||
)
|
||||
rendered = render_comment(CheckRunResult(pr_number=1, packages=[pkg]))
|
||||
@@ -57,6 +59,8 @@ def test_render_needs_agent_emits_generic_placeholders() -> None:
|
||||
assert "{{CHECK_CELL:pkg:release_pipeline}}" in rendered
|
||||
assert "{{CHECK_DETAIL:pkg:release_pipeline}}" in rendered
|
||||
assert "{{CHECK_CELL:pkg:pr_link}}" in rendered
|
||||
assert "{{CHECK_CELL:pkg:async_blocking}}" in rendered
|
||||
assert "{{CHECK_DETAIL:pkg:async_blocking}}" in rendered
|
||||
assert "<details open>" in rendered
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ def test_runner_attestation_recognised(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert pkg.checks[CheckKind.RELEASE_PIPELINE].status == CheckStatus.PASS
|
||||
assert pkg.checks[CheckKind.REPO_PUBLIC].status == CheckStatus.NEEDS_AGENT
|
||||
assert pkg.checks[CheckKind.PR_LINK].status == CheckStatus.NEEDS_AGENT
|
||||
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.NEEDS_AGENT
|
||||
assert result.needs_agent is True
|
||||
|
||||
|
||||
@@ -154,9 +155,10 @@ def test_runner_marks_missing_version_as_fail(
|
||||
pkg = result.packages[0]
|
||||
assert pkg.checks[CheckKind.CI_UPLOAD].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.RELEASE_PIPELINE].status == CheckStatus.FAIL
|
||||
# No repo URL → repo_public and pr_link short-circuit to FAIL, not NEEDS_AGENT
|
||||
# No repo URL → repo_public, pr_link and async_blocking short-circuit to FAIL
|
||||
assert pkg.checks[CheckKind.REPO_PUBLIC].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.PR_LINK].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.FAIL
|
||||
assert result.needs_agent is False
|
||||
|
||||
|
||||
@@ -191,9 +193,81 @@ def test_runner_pypi_found_but_no_repo_url_fails_repo_checks(
|
||||
pkg = result.packages[0]
|
||||
assert pkg.checks[CheckKind.REPO_PUBLIC].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.PR_LINK].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.FAIL
|
||||
assert "does not advertise" in pkg.checks[CheckKind.REPO_PUBLIC].details
|
||||
|
||||
|
||||
def test_runner_async_blocking_new_package_full_review(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A newly added package asks the agent for a full-tree async review."""
|
||||
_patch_pypi(
|
||||
monkeypatch,
|
||||
PypiPackageInfo(
|
||||
project_urls={"Source": "https://github.com/x/y"},
|
||||
repo_url="https://github.com/x/y",
|
||||
file_provenance_urls=["whatever"],
|
||||
found=True,
|
||||
),
|
||||
ProvenanceResult(
|
||||
has_attestation=True,
|
||||
publisher_kind="GitHub",
|
||||
recognized_publisher=True,
|
||||
detail="ok",
|
||||
),
|
||||
)
|
||||
diff = (
|
||||
"diff --git a/requirements_all.txt b/requirements_all.txt\n"
|
||||
"--- a/requirements_all.txt\n"
|
||||
"+++ b/requirements_all.txt\n"
|
||||
"@@ -0,0 +1 @@\n"
|
||||
"+pkg==1.0.0\n"
|
||||
)
|
||||
result = run_checks(pr_number=1, diff_text=diff)
|
||||
pkg = result.packages[0]
|
||||
assert pkg.old_version is None
|
||||
detail = pkg.checks[CheckKind.ASYNC_BLOCKING].details
|
||||
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.NEEDS_AGENT
|
||||
assert "New dependency" in detail
|
||||
assert "entire source tree" in detail
|
||||
|
||||
|
||||
def test_runner_async_blocking_version_bump_diff_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A version bump asks the agent to review only the diff for new blocking calls."""
|
||||
_patch_pypi(
|
||||
monkeypatch,
|
||||
PypiPackageInfo(
|
||||
project_urls={"Source": "https://github.com/x/y"},
|
||||
repo_url="https://github.com/x/y",
|
||||
file_provenance_urls=["whatever"],
|
||||
found=True,
|
||||
),
|
||||
ProvenanceResult(
|
||||
has_attestation=True,
|
||||
publisher_kind="GitHub",
|
||||
recognized_publisher=True,
|
||||
detail="ok",
|
||||
),
|
||||
)
|
||||
diff = (
|
||||
"diff --git a/requirements_all.txt b/requirements_all.txt\n"
|
||||
"--- a/requirements_all.txt\n"
|
||||
"+++ b/requirements_all.txt\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-pkg==1.0.0\n"
|
||||
"+pkg==1.1.0\n"
|
||||
)
|
||||
result = run_checks(pr_number=1, diff_text=diff)
|
||||
pkg = result.packages[0]
|
||||
assert pkg.old_version == "1.0.0"
|
||||
detail = pkg.checks[CheckKind.ASYNC_BLOCKING].details
|
||||
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.NEEDS_AGENT
|
||||
assert "1.0.0" in detail and "1.1.0" in detail
|
||||
assert "diff" in detail
|
||||
|
||||
|
||||
def test_runner_serialises_to_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The artifact contract: `to_dict()` is JSON-serialisable with expected keys."""
|
||||
_patch_pypi(
|
||||
|
||||
Reference in New Issue
Block a user