mirror of
https://github.com/home-assistant/core.git
synced 2026-06-15 13:51:58 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a03155c9 | |||
| 437d33d791 | |||
| 770488f0d4 | |||
| cefbb109d2 | |||
| d9aa99e338 | |||
| df49891f40 | |||
| 9c86fe2ac5 | |||
| 29badf6651 | |||
| bd58c08eea | |||
| b69c13477a | |||
| ea5e8e7982 | |||
| dfa40f807e | |||
| fdb15ce2d7 | |||
| ee30f6c085 | |||
| d7af8ed2b3 | |||
| 1631fe58ec | |||
| 6c1540130c | |||
| 2c801453ab | |||
| 1ea8c5d037 |
+17
-19
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","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"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -59,15 +59,13 @@ permissions: {}
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
|
||||
run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs:
|
||||
- extract_pr_number
|
||||
- pre_activation
|
||||
needs: pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
@@ -191,20 +189,20 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -233,12 +231,12 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -323,7 +321,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: "gh-aw-copilot-${{ github.workflow }}"
|
||||
@@ -453,9 +450,9 @@ jobs:
|
||||
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
|
||||
mkdir -p /tmp/gh-aw/safeoutputs
|
||||
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_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
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -647,7 +644,7 @@ jobs:
|
||||
|
||||
mkdir -p /home/runner/.copilot
|
||||
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
|
||||
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_d99df59573a98681_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -657,7 +654,7 @@ jobs:
|
||||
"GITHUB_HOST": "\${GITHUB_SERVER_URL}",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
|
||||
"GITHUB_READ_ONLY": "1",
|
||||
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
|
||||
"GITHUB_TOOLSETS": "repos,pull_requests"
|
||||
},
|
||||
"guard-policies": {
|
||||
"allow-only": {
|
||||
@@ -691,7 +688,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
GH_AW_MCP_CONFIG_d99df59573a98681_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -1284,6 +1281,7 @@ jobs:
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
network:
|
||||
allowed:
|
||||
@@ -14,7 +13,7 @@ network:
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [default, actions]
|
||||
toolsets: [repos, pull_requests]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
@@ -44,7 +43,7 @@ jobs:
|
||||
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 }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
@@ -83,296 +82,289 @@ description: >
|
||||
|
||||
# Check requirements (AW)
|
||||
|
||||
You are a code review assistant for the Home Assistant project. The
|
||||
deterministic stage has already evaluated every check it can on its own
|
||||
and produced an artifact containing the PR number, per-package check
|
||||
results, and a pre-rendered comment with placeholders. **Your only job is
|
||||
to read that artifact, resolve any `needs_agent` checks, and post the
|
||||
final comment.**
|
||||
You are a code-review assistant for Home Assistant. The deterministic
|
||||
stage already evaluated every check it can and produced an artifact at
|
||||
`/tmp/gh-aw/deterministic/results.json`. Your only job is to resolve any
|
||||
`needs_agent` checks and post the rendered comment.
|
||||
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
## Step 1 — Read the artifact
|
||||
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
Read the JSON directly for the full schema. Key fields:
|
||||
|
||||
The JSON has this shape:
|
||||
- `pr_number`, `needs_agent` (bool), `packages[]`, `rendered_comment`.
|
||||
- Each `package`: `name`, `old_version` (`null` if new), `new_version`,
|
||||
`repo_url`, `publisher_kind`, `checks` (keyed by check-kind, each
|
||||
with `status` of `pass`/`warn`/`fail`/`needs_agent` and `details`).
|
||||
- `rendered_comment` contains, for each `needs_agent` check, two
|
||||
placeholders to replace:
|
||||
- `{{CHECK_CELL:<pkg>:<kind>}}` → exactly one of `✅`, `☑️`, `⚠️`, `❌`. The
|
||||
**`security`** check kind uses `☑️` instead of `✅` for the success
|
||||
case — see its section below for why.
|
||||
- `{{CHECK_DETAIL:<pkg>:<kind>}}` → `<icon> <one-line explanation>`
|
||||
(the bullet's `- **<label>**:` prefix is already rendered; replace
|
||||
only the placeholder).
|
||||
|
||||
- `pr_number` — the PR being checked. The `add_comment` safe-output is
|
||||
already targeted at this PR (a pre-job extracts `pr_number` from the
|
||||
artifact and the workflow wires it into the safe-output config via
|
||||
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
|
||||
set `item_number` yourself** — just emit `add_comment` with the
|
||||
rendered body.
|
||||
- `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
|
||||
previous pin), `new_version`, `repo_url`, `publisher_kind`.
|
||||
- `checks` — a dict keyed by **check kind** (string). Each value has a
|
||||
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
|
||||
- `rendered_comment` — the final PR comment body, already rendered. For
|
||||
every check whose status is `needs_agent` it contains two placeholders
|
||||
you must replace:
|
||||
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
|
||||
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
|
||||
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
|
||||
in the package's `<details>` block. Replace with
|
||||
`<icon> <one-line explanation>` (the bullet's leading
|
||||
`- **<label>**:` is already rendered — replace only the placeholder).
|
||||
|
||||
You **must not** modify any other content in `rendered_comment`. Do not
|
||||
re-evaluate checks that already have a deterministic status. Do not add
|
||||
or remove packages.
|
||||
Do not modify other content in `rendered_comment`, do not re-evaluate
|
||||
deterministic checks, do not add or remove packages. If `needs_agent`
|
||||
is `false`, emit `rendered_comment` unchanged.
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
|
||||
For each `package` in `packages`:
|
||||
For each `(package, check_kind)` with `status == "needs_agent"`, find
|
||||
the matching `### Check kind: <check_kind>` section below and follow
|
||||
it. If no section matches, emit a single `add_comment` with:
|
||||
|
||||
For each `(check_kind, result)` in `package.checks` where
|
||||
`result.status == "needs_agent"`:
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
1. Look up `## Check kind: <check_kind>` in the **Check instructions**
|
||||
section below.
|
||||
2. **If no matching section exists**: emit a single `add_comment` whose
|
||||
body is:
|
||||
❌ Internal error: deterministic artifact contains an unknown check kind
|
||||
(`<check_kind>` on `<pkg>`).
|
||||
```
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
❌ Internal error: the deterministic artifact contains a check kind
|
||||
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
|
||||
instructions for. Update `.github/workflows/check-requirements.md`
|
||||
to add a matching `## Check kind: <check_kind>` section, or remove
|
||||
the kind from the deterministic stage.
|
||||
```
|
||||
|
||||
Then stop. **Do not improvise** a verdict for an unknown check kind.
|
||||
3. Otherwise, follow the instructions in that section. They tell you
|
||||
which icon (✅/⚠️/❌) and one-line explanation to produce.
|
||||
Then stop. Do not improvise a verdict.
|
||||
|
||||
## Step 3 — Post the comment
|
||||
|
||||
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
|
||||
in `rendered_comment` with the resolved value.
|
||||
2. Emit the resulting markdown using `add_comment` — set `body` to the
|
||||
merged `rendered_comment` verbatim (the leading
|
||||
`<!-- requirements-check -->` marker must be preserved). The PR
|
||||
target is already set by the workflow; do not pass `item_number`.
|
||||
|
||||
If the artifact's top-level `needs_agent` is `false` (no checks need
|
||||
you), emit `rendered_comment` unchanged.
|
||||
Replace every placeholder with the resolved value and emit
|
||||
`rendered_comment` via `add_comment`. Preserve the leading
|
||||
`<!-- requirements-check -->` marker. The PR target is already wired;
|
||||
do not pass `item_number`.
|
||||
|
||||
## Check instructions
|
||||
|
||||
### Check kind: `repo_public`
|
||||
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
`web-fetch` GET `package.repo_url`.
|
||||
- 200 + public repo page → ✅ `<repo_url> is publicly accessible.`
|
||||
- 4xx/5xx or login redirect → ❌ `Source repository at <repo_url> is
|
||||
not publicly accessible. Home Assistant requires dependencies to
|
||||
have publicly available source code.`
|
||||
- Otherwise → ⚠️ with a one-line description.
|
||||
|
||||
1. Read `package.repo_url`.
|
||||
2. Use the `web-fetch` tool to GET that URL.
|
||||
3. Decide the verdict:
|
||||
- HTTP 200, returns a public repository page → ✅
|
||||
`<repo_url> is publicly accessible.`
|
||||
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
|
||||
❌ `Source repository at <repo_url> is not publicly accessible.
|
||||
Home Assistant requires all dependencies to have publicly available
|
||||
source code.`
|
||||
- Any other inconclusive result → ⚠️ with a one-line description.
|
||||
|
||||
If `repo_public` resolves to ❌ for a package, **also** mark that
|
||||
package's `release_pipeline` and `async_blocking` cells/details as `—`
|
||||
(em dash) and explain `Skipped because the source repository is not
|
||||
publicly accessible.` — neither check can be performed without a public
|
||||
repo.
|
||||
If ❌, also mark this package's `release_pipeline` and `async_blocking`
|
||||
cells/details as `—` and explain `Skipped because the source
|
||||
repository is not publicly accessible.`.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
Verify the PR description contains the right link for the change.
|
||||
Fetch the PR body via the `pull_requests` MCP using `pr_number`. Extract URLs.
|
||||
|
||||
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
|
||||
field from the artifact.
|
||||
2. Extract all URLs from the body.
|
||||
3. For a **new package** (`package.old_version` is `null`):
|
||||
- The PR body must contain a URL that points at `package.repo_url`
|
||||
(any sub-path of the same `owner/repo` on the same host is
|
||||
acceptable). A PyPI link is **not** sufficient.
|
||||
- ✅ if such a URL is present.
|
||||
- ❌ otherwise:
|
||||
`PR description must link to the source repository at <repo_url>.
|
||||
A PyPI page link is not sufficient.`
|
||||
4. For a **version bump** (`package.old_version` is not `null`):
|
||||
- The PR body must contain a URL on the same host as
|
||||
`package.repo_url` that references **both** `package.old_version`
|
||||
and `package.new_version` (e.g. a GitHub compare URL
|
||||
`compare/vX...vY`, a release / changelog URL containing both
|
||||
versions, etc.).
|
||||
- ✅ if such a URL is present and the versions match the actual bump.
|
||||
- ❌ otherwise:
|
||||
`PR description should link to a changelog or compare URL on
|
||||
<repo_url> that mentions both <old_version> and <new_version>.`
|
||||
- **New package** (`old_version == null`): body must contain a URL
|
||||
pointing at `repo_url`'s `owner/repo` on the same host (any
|
||||
sub-path OK). PyPI is not sufficient.
|
||||
- ✅ if present; otherwise ❌ `PR description must link to the
|
||||
source repository at <repo_url>. A PyPI page link is not
|
||||
sufficient.`
|
||||
- **Version bump**: body must contain a URL on the same host as
|
||||
`repo_url` that mentions **both** `old_version` and `new_version`
|
||||
(compare URL, changelog, release page).
|
||||
- ✅ if present and versions match; otherwise ❌ `PR description
|
||||
should link to a changelog or compare URL on <repo_url> that
|
||||
mentions both <old_version> and <new_version>.`
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
Inspect the upstream's publish-to-PyPI CI. Host-specific lookup, same
|
||||
rubric:
|
||||
|
||||
For each package needing inspection, determine the source repository
|
||||
host from `package.repo_url`, then apply the corresponding checklist.
|
||||
|
||||
#### GitHub repositories (`github.com`)
|
||||
|
||||
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
|
||||
2. Identify any workflow whose name or filename suggests publishing to
|
||||
PyPI (`release`, `publish`, `pypi`, or `deploy`).
|
||||
3. Fetch the workflow file and check:
|
||||
- **Trigger sanity**: triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job —
|
||||
**not** solely `workflow_dispatch` with no environment-protection
|
||||
guard.
|
||||
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
|
||||
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
|
||||
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
|
||||
- **No manual upload bypass**: no ungated `twine upload` or
|
||||
`pip upload`.
|
||||
4. Verdict:
|
||||
- ✅ if OIDC + sane triggers + no bypass.
|
||||
- ⚠️ if static token but version bump, or details unclear.
|
||||
- ❌ if static token on a new package, or only-manual triggers with
|
||||
no environment protection.
|
||||
|
||||
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
|
||||
2. Fetch `.gitlab-ci.yml` via
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
3. Apply the same conceptual checks: tag-only / protected-branch
|
||||
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
|
||||
ungated `twine upload`. Same verdict rules as GitHub.
|
||||
|
||||
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
|
||||
|
||||
1. Use `web-fetch` to retrieve any visible CI configuration
|
||||
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
|
||||
`bitbucket-pipelines.yml`, `.builds/*.yml`).
|
||||
2. Apply the conceptual checks: automated triggers, CI-injected
|
||||
credentials, no manual `twine upload`.
|
||||
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
|
||||
inspected; hosting provider is not GitHub or GitLab.`
|
||||
1. Locate the publish workflow / job (name or filename contains
|
||||
`release`, `publish`, `pypi`, or `deploy`).
|
||||
- GitHub: list `.github/workflows/` via the `repos` MCP, pick the
|
||||
promising file by name, fetch its contents.
|
||||
- GitLab: fetch `.gitlab-ci.yml` from the default ref via
|
||||
`https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
- Other hosts: `web-fetch` an obvious CI config
|
||||
(`.circleci/config.yml`, `bitbucket-pipelines.yml`, etc.).
|
||||
2. Apply this rubric:
|
||||
- **Trigger**: tag push / `release: published` / protected branch —
|
||||
not solely manual dispatch without an environment guard.
|
||||
- **Credentials**: OIDC (`id-token: write` +
|
||||
`pypa/gh-action-pypi-publish` or equivalent) preferred; static
|
||||
`PYPI_TOKEN` from a CI secret acceptable for a bump.
|
||||
- **No bypass**: no ungated `twine upload` / `pip upload`.
|
||||
3. Verdict:
|
||||
- ✅ — OIDC + sane triggers + no bypass.
|
||||
- ⚠️ — static token on a bump, details unclear, or
|
||||
non-GitHub/GitLab host with limited CI visibility.
|
||||
- ❌ — static token on a new package, or manual-only triggers
|
||||
without environment protection.
|
||||
|
||||
### 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.
|
||||
Verify the dependency does not call blocking APIs inside `async def`
|
||||
bodies. Home Assistant runs on a single asyncio loop, so blocking
|
||||
calls from the async surface stall the whole loop. A purely sync
|
||||
library is fine — integrations wrap its calls in an executor.
|
||||
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
**Mode** (decided by `old_version`):
|
||||
- `null` → new package: review the entire current source tree.
|
||||
- string → version bump: review only the diff between the two tags.
|
||||
Blocking calls already present in `old_version` are not regressions.
|
||||
|
||||
- `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 — async surface?**
|
||||
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
Fetch `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` at the
|
||||
tag matching `new_version` (try `v{version}`, `{version}`,
|
||||
`release-{version}` — at most three attempts). Use the `repos` MCP for
|
||||
github.com, `web-fetch` otherwise.
|
||||
|
||||
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}`).
|
||||
If sync-only (no `async def` in public modules; no
|
||||
asyncio/aiohttp/httpx/anyio in deps; no `Framework :: AsyncIO`
|
||||
classifier) → ✅ `Sync-only library; Home Assistant integrations must
|
||||
wrap calls in an executor.` (Same verdict for both modes.)
|
||||
|
||||
- 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.
|
||||
**Step 2 — review the 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.*
|
||||
- New package: grep public modules for `async def`, inspect each
|
||||
async body and transitive helpers.
|
||||
- Bump: fetch the compare diff
|
||||
(`/repos/{owner}/{repo}/compare/{old}...{new}` on GitHub, equivalent
|
||||
on GitLab/other hosts). Only flag patterns on **added** lines that
|
||||
are inside or reachable from `async def`. If no tag format resolves,
|
||||
fall back to a full review and note that the diff was unavailable.
|
||||
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
**Blocking patterns to flag inside `async def`:**
|
||||
|
||||
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`,
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(`, `pycurl`.
|
||||
- `time.sleep(` (use `await asyncio.sleep(`).
|
||||
- Sync sockets/SSL: bare `socket.socket` I/O, `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_*`).
|
||||
- File I/O on the request path: `open(` /
|
||||
`pathlib.Path.read_*` / `.write_*` for non-trivial sizes (small
|
||||
one-shot reads during import are OK).
|
||||
- Sync DB drivers: `sqlite3`, `psycopg2`, `pymysql`, sync `pymongo` /
|
||||
`redis.Redis`.
|
||||
- `subprocess.run` / `subprocess.call` / `os.system`.
|
||||
|
||||
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.
|
||||
Calls dispatched to an executor (`run_in_executor`,
|
||||
`asyncio.to_thread`, `anyio.to_thread.run_sync`) do **not** count as
|
||||
blocking.
|
||||
|
||||
#### Step 4 — Verdict
|
||||
**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.
|
||||
- ✅ — no offending pattern. Bumps: phrase as `No new blocking calls
|
||||
introduced in {old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking only in sync helpers the async API never calls, or
|
||||
clearly off the hot path (e.g. one-shot pre-loop setup). Cite at
|
||||
least one `<file>:<line>` and say why it's not hot.
|
||||
- ❌ — blocking call reachable from a public `async def` on the
|
||||
request/polling path (bump: introduced or moved onto the hot path
|
||||
by this version). Cite the offending `<file>:<line>` as a clickable
|
||||
link on the repo host.
|
||||
|
||||
### Check kind: `security`
|
||||
|
||||
**Baseline** scan of the upstream source for obvious supply-chain red
|
||||
flags — a cheap first pass, **not** a security review or malware audit.
|
||||
A clean result means "nothing obvious stood out", not "this package is
|
||||
safe". The success icon is `☑️` — **never** `✅` — so a passing scan is
|
||||
not read as an endorsement.
|
||||
|
||||
If `repo_public` resolves to ❌ for the same package, mark `security`'s
|
||||
cell and detail as `—` and explain `Skipped because the source
|
||||
repository is not publicly accessible.` — the source cannot be fetched.
|
||||
|
||||
**Step 1 — Fetch a representative slice**
|
||||
|
||||
Locate the source from `package.repo_url`.
|
||||
|
||||
- GitHub: resolve the default branch (`GET /repos/{owner}/{repo}`), list
|
||||
the tree (`GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1`),
|
||||
find the module dir (`{name}/` or `src/{name}/`, normalising `-` ↔ `_`).
|
||||
- GitLab: equivalent REST calls. Other hosts: `web-fetch` raw file URLs.
|
||||
|
||||
Fetch the **raw contents** of `setup.py` (install-time code runs on every
|
||||
consumer), `pyproject.toml` (`[build-system]` / custom backend), the
|
||||
package's `__init__.py`, and co — prioritising `entry_points` targets, plus any name suggesting
|
||||
bootstrap / loader / self-update (`update*.py`, `loader*.py`,
|
||||
`bootstrap*.py`, `_native.py`, `_post_install*.py`, …).
|
||||
|
||||
If the tree is too large for the API budget, inspect at least `setup.py`,
|
||||
`pyproject.toml`, and `__init__.py`, then return ⚠️ noting the partial scan.
|
||||
|
||||
**Step 2 — Patterns to flag**
|
||||
|
||||
Reason from principles, not a fixed checklist: for each file ask *would a
|
||||
well-behaved library doing what this package's PyPI description claims
|
||||
need to do this?* If "no" or "unclear", record a finding. The categories
|
||||
describe the **shape** of concerning behavior; the named APIs, filenames,
|
||||
and keys are illustrative — treat any equivalent construct (including ones
|
||||
that did not exist when this was written) the same way.
|
||||
|
||||
For every finding include the file path, line number, a snippet
|
||||
(≤ 120 chars), a permalink
|
||||
(`https://github.com/{owner}/{repo}/blob/{sha}/{path}#L{line}` or the
|
||||
GitLab equivalent), and one sentence on why it is out of scope.
|
||||
|
||||
1. **Reaches into Home Assistant internals.** A library should touch HA
|
||||
only through its documented Python API — never the `config_dir`
|
||||
filesystem or internal auth / session state. Flag code that opens,
|
||||
reads, writes, or resolves paths to artifacts it does not own
|
||||
(top-level YAML it did not create, anything under `.storage/`, other
|
||||
integrations' files) or reads tokens / refresh tokens / auth providers
|
||||
(e.g. `secrets.yaml`, `.storage/auth*`, `hass.auth`). The principle is
|
||||
*out-of-scope access*, not a static list of names.
|
||||
2. **Network input flows into an execution sink (download-and-execute).**
|
||||
Flag any data-flow from a network response body (any HTTP / WebSocket /
|
||||
raw-socket client, sync or async) to an execution sink: `exec`, `eval`,
|
||||
`compile`, `marshal.loads`, `pickle.loads`, `types.FunctionType`,
|
||||
`importlib.util.spec_from_loader`, `subprocess.*`, `os.system`, shell
|
||||
pipelines (`curl … | sh`), or a file later imported / executed — plus
|
||||
package-manager calls (`pip install` / `download`) with args resolved
|
||||
from network responses at runtime.
|
||||
3. **Build / install-time code is non-deterministic or non-local.**
|
||||
`setup.py`, `setup.cfg` `cmdclass`, custom PEP 517 backends, and other
|
||||
build hooks must only compile and copy files shipped in the sdist. Flag
|
||||
build-stage code that opens a socket, shells out, writes outside the
|
||||
build / install tree, or pulls a build backend not on PyPI (Git URL /
|
||||
local path).
|
||||
4. **Reads secrets and combines them with an egress path.** The shape is
|
||||
*secret-source → outbound-channel*. Flag code that reads credential
|
||||
material (token-like env vars, credential files under the user's home,
|
||||
OS keychain APIs, browser-profile dirs, HA token stores) **and** in the
|
||||
same path sends it to a destination the package needn't talk to.
|
||||
Reading or sending alone is not enough — the *combination* is the signal.
|
||||
5. **Hides what it does.** Flag opaque data flowing into an execution
|
||||
sink: large encoded / compressed / hex strings (`base64`, `codecs`,
|
||||
`zlib`, `lzma`, `bytes.fromhex`, or any equivalent) passed to `exec` /
|
||||
`eval` / `compile` / `__import__`; identifiers assembled at runtime
|
||||
then imported; or any construct whose evident purpose is to make the
|
||||
behavior unreadable.
|
||||
6. **Hard-coded network destination off-purpose.** Flag outbound URLs or
|
||||
hosts absent from the package's PyPI `project_urls` with no obvious
|
||||
connection to its function — short-link / paste services, ephemeral
|
||||
tunnels, raw IPs, non-default ports against unknown hosts — and any
|
||||
network call at module top-level / `__init__.py` (runs on import for
|
||||
every consumer).
|
||||
|
||||
A clearly out-of-scope behavior that fits none of the above: flag under
|
||||
the closest category and explain. The categories guide reasoning, not bound it.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Aggregate the findings into one of:
|
||||
|
||||
- `☑️ Baseline scan found nothing obvious in <list of inspected files>.
|
||||
This is not a security review — only the cheap checks were run.`
|
||||
Use `☑️` (**not** `✅`) so a passing scan is not read as an endorsement.
|
||||
- `⚠️ <one-line summary>` — patterns with plausible legitimate uses;
|
||||
include path / line / snippet / permalink per match for the reviewer.
|
||||
- `❌ <one-line summary>` — patterns with no legitimate explanation
|
||||
(install-time network execution, decode-and-exec of opaque blobs, reads
|
||||
of `secrets.yaml` / `.storage/auth*`, token exfiltration to an external
|
||||
host); same detail.
|
||||
|
||||
Be precise. False positives are expected — when in doubt prefer `⚠️` with
|
||||
context over `❌`. This check is informational and never blocks the
|
||||
workflow on its own; a human reviewer decides whether to merge.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive and helpful. Reference the inspected workflow / CI
|
||||
file by URL where useful so the contributor can fix the issue.
|
||||
- The dedup of the requirements-check comment is handled by gh-aw's
|
||||
`add_comment` safe-output via the `<!-- requirements-check -->`
|
||||
marker on the first line of `rendered_comment`.
|
||||
- If the deterministic workflow concluded with a non-success status,
|
||||
this workflow's `if:` guard on `Download deterministic-results
|
||||
artifact` skipped the download. If you find no file at
|
||||
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
|
||||
verification is also gated and will not complain.
|
||||
- Be constructive; reference the inspected file by URL when useful.
|
||||
- Comment dedup is handled by gh-aw's `add_comment` safe-output via
|
||||
the `<!-- requirements-check -->` marker.
|
||||
- If `/tmp/gh-aw/deterministic/results.json` is missing (upstream
|
||||
cancelled/failed), emit nothing — the post-step verification is
|
||||
gated and won't complain.
|
||||
@@ -31,7 +31,7 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -144,7 +144,9 @@ def _sensor_device_info_to_hass(
|
||||
adv: Aranet4Advertisement,
|
||||
) -> DeviceInfo:
|
||||
"""Convert a sensor device info to hass device info."""
|
||||
hass_device_info = DeviceInfo({})
|
||||
hass_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, adv.device.address)}
|
||||
)
|
||||
if adv.readings and adv.readings.name:
|
||||
hass_device_info[ATTR_NAME] = adv.readings.name
|
||||
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["mozart-api==5.3.1.108.2"],
|
||||
"requirements": ["mozart-api==6.2.0.44.0"],
|
||||
"zeroconf": ["_bangolufsen._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ BINARY_SENSOR_TYPES = (
|
||||
key="open",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="input",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.23.2"]
|
||||
"requirements": ["bthome-ble==3.23.4"]
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ class EventTrigger(Trigger):
|
||||
listener = TargetCalendarEventListener(
|
||||
self._hass, target_selection, self._event_type, offset, run_action
|
||||
)
|
||||
return listener.async_setup()
|
||||
return await listener.async_setup()
|
||||
|
||||
|
||||
class EventStartedTrigger(EventTrigger):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Diagnostics support for Daikin."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import KEY_MAC
|
||||
from .coordinator import DaikinConfigEntry
|
||||
|
||||
TO_REDACT_ENTRY = {CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID, KEY_MAC}
|
||||
TO_REDACT_DEVICE = {"mac"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: DaikinConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
device = entry.runtime_data.device
|
||||
return {
|
||||
"entry_data": async_redact_data(dict(entry.data), TO_REDACT_ENTRY),
|
||||
"device": {
|
||||
"values": async_redact_data(dict(device.values), TO_REDACT_DEVICE),
|
||||
"support_away_mode": device.support_away_mode,
|
||||
"support_advanced_modes": device.support_advanced_modes,
|
||||
"support_fan_rate": device.support_fan_rate,
|
||||
"support_swing_mode": device.support_swing_mode,
|
||||
"support_outside_temperature": device.support_outside_temperature,
|
||||
"support_humidity": device.support_humidity,
|
||||
"support_energy_consumption": device.support_energy_consumption,
|
||||
"support_compressor_frequency": device.support_compressor_frequency,
|
||||
},
|
||||
}
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.5"]
|
||||
"requirements": ["home-assistant-frontend==20260527.6"]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ CONF_COMMANDS = "commands"
|
||||
CONF_DATA = "data"
|
||||
CONF_IR_COUNT = "ir_count"
|
||||
|
||||
EMPTY_COMMAND_PLACEHOLDER = '""'
|
||||
|
||||
PLATFORM_SCHEMA = REMOTE_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_MAC): cv.string,
|
||||
@@ -69,6 +71,35 @@ PLATFORM_SCHEMA = REMOTE_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def _format_command_value(value: str) -> str:
|
||||
"""Format a command name or command data value."""
|
||||
value = value.strip()
|
||||
return value or EMPTY_COMMAND_PLACEHOLDER
|
||||
|
||||
|
||||
def _format_command_table(commands: Iterable[dict[str, str]]) -> str:
|
||||
"""Format YAML commands for pyitachip2ir."""
|
||||
return "".join(
|
||||
f"{_format_command_value(command[CONF_NAME])}\n"
|
||||
f"{_format_command_value(command[CONF_DATA])}\n"
|
||||
for command in commands
|
||||
)
|
||||
|
||||
|
||||
def _setup_remote_entity(
|
||||
itachip2ir: Any, device_config: dict[str, Any]
|
||||
) -> ITachIP2IRRemote:
|
||||
"""Create an iTach remote entity from YAML device config."""
|
||||
name = device_config.get(CONF_NAME)
|
||||
modaddr = int(device_config.get(CONF_MODADDR, DEFAULT_MODADDR))
|
||||
connaddr = int(device_config.get(CONF_CONNADDR, DEFAULT_CONNADDR))
|
||||
ir_count = int(device_config.get(CONF_IR_COUNT, DEFAULT_IR_COUNT))
|
||||
command_table = _format_command_table(device_config[CONF_COMMANDS])
|
||||
|
||||
itachip2ir.addDevice(name, modaddr, connaddr, command_table)
|
||||
return ITachIP2IRRemote(itachip2ir, name, ir_count)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -84,30 +115,17 @@ def setup_platform(
|
||||
_LOGGER.error("Unable to find iTach")
|
||||
return
|
||||
|
||||
devices = []
|
||||
for data in config[CONF_DEVICES]:
|
||||
name = data.get(CONF_NAME)
|
||||
modaddr = int(data.get(CONF_MODADDR, DEFAULT_MODADDR))
|
||||
connaddr = int(data.get(CONF_CONNADDR, DEFAULT_CONNADDR))
|
||||
ir_count = int(data.get(CONF_IR_COUNT, DEFAULT_IR_COUNT))
|
||||
cmddatas = ""
|
||||
for cmd in data.get(CONF_COMMANDS):
|
||||
cmdname = cmd[CONF_NAME].strip()
|
||||
if not cmdname:
|
||||
cmdname = '""'
|
||||
cmddata = cmd[CONF_DATA].strip()
|
||||
if not cmddata:
|
||||
cmddata = '""'
|
||||
cmddatas += f"{cmdname}\n{cmddata}\n"
|
||||
itachip2ir.addDevice(name, modaddr, connaddr, cmddatas)
|
||||
devices.append(ITachIP2IRRemote(itachip2ir, name, ir_count))
|
||||
devices = [
|
||||
_setup_remote_entity(itachip2ir, device_config)
|
||||
for device_config in config[CONF_DEVICES]
|
||||
]
|
||||
add_entities(devices, True)
|
||||
|
||||
|
||||
class ITachIP2IRRemote(remote.RemoteEntity):
|
||||
"""Device that sends commands to an ITachIP2IR device."""
|
||||
|
||||
def __init__(self, itachip2ir, name, ir_count):
|
||||
def __init__(self, itachip2ir: Any, name: str | None, ir_count: int) -> None:
|
||||
"""Initialize device."""
|
||||
self.itachip2ir = itachip2ir
|
||||
self._attr_is_on = False
|
||||
|
||||
@@ -422,9 +422,7 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
"Invalid alarm code provided",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_code",
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Binary sensor platform for MELCloud Home."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiomelcloudhome import ATAUnit, ATWUnit
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ATABinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Class to hold MELCloud Home ATA binary sensor description."""
|
||||
|
||||
state_fn: Callable[[ATAUnit], bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ATWBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Class to hold MELCloud Home ATW binary sensor description."""
|
||||
|
||||
state_fn: Callable[[ATWUnit], bool | None]
|
||||
|
||||
|
||||
ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = (
|
||||
ATABinarySensorEntityDescription(
|
||||
key="error",
|
||||
translation_key="error",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
state_fn=lambda unit: unit.is_in_error,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATABinarySensorEntityDescription(
|
||||
key="standby",
|
||||
translation_key="standby",
|
||||
state_fn=lambda unit: unit.in_standby_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
ATW_SENSORS: tuple[ATWBinarySensorEntityDescription, ...] = (
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="error",
|
||||
translation_key="error",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
state_fn=lambda unit: unit.is_in_error,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="standby",
|
||||
translation_key="standby",
|
||||
state_fn=lambda unit: unit.in_standby_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="forced_hot_water",
|
||||
translation_key="forced_hot_water",
|
||||
state_fn=lambda unit: unit.forced_hot_water_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MelCloudHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MELCloud Home binary sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATABinarySensor(coordinator, entity_description, unit)
|
||||
for entity_description in ATA_SENSORS
|
||||
for unit in units
|
||||
)
|
||||
|
||||
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATWBinarySensor(coordinator, entity_description, unit)
|
||||
for entity_description in ATW_SENSORS
|
||||
for unit in units
|
||||
)
|
||||
|
||||
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
|
||||
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
|
||||
|
||||
_async_add_new_ata_units(list(coordinator.ata_units.values()))
|
||||
_async_add_new_atw_units(list(coordinator.atw_units.values()))
|
||||
|
||||
|
||||
class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity):
|
||||
"""Representation of a MELCloud Home ATA binary sensor."""
|
||||
|
||||
entity_description: ATABinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
entity_description: ATABinarySensorEntityDescription,
|
||||
unit: ATAUnit,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.unit)
|
||||
|
||||
|
||||
class ATWBinarySensor(MelCloudHomeATWUnitEntity, BinarySensorEntity):
|
||||
"""Representation of a MELCloud Home ATW binary sensor."""
|
||||
|
||||
entity_description: ATWBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
entity_description: ATWBinarySensorEntityDescription,
|
||||
unit: ATWUnit,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.unit)
|
||||
@@ -103,7 +103,6 @@ async def async_setup_entry(
|
||||
async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units)
|
||||
|
||||
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
|
||||
# Erwin: create zone 1 for all units, and zone 2 only when the unit supports it.
|
||||
async_add_entities(
|
||||
ATWZoneClimateEntity(coordinator, unit, zone_number)
|
||||
for unit in units
|
||||
@@ -186,12 +185,12 @@ class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current room temperature."""
|
||||
return self.unit.room_temperature if self.unit else None
|
||||
return self.unit.room_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self.unit.set_temperature if self.unit else None
|
||||
return self.unit.set_temperature
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
|
||||
@@ -26,7 +26,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_EMAIL): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username")
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
|
||||
@@ -15,7 +15,6 @@ class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]):
|
||||
"""Base entity for MELCloud Home."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name: str | None = None
|
||||
|
||||
|
||||
class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"forced_hot_water": {
|
||||
"default": "mdi:water-boiler"
|
||||
},
|
||||
"standby": {
|
||||
"default": "mdi:power-sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,22 @@
|
||||
"email": "Email address for your MELCloud Home account.",
|
||||
"password": "Password for your MELCloud Home account."
|
||||
},
|
||||
"description": "Login to MELCloud Home with the email address and password associated with your account."
|
||||
"description": "Log in to MELCloud Home with the email address and password associated with your account."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"error": {
|
||||
"name": "Error"
|
||||
},
|
||||
"forced_hot_water": {
|
||||
"name": "Forced hot water"
|
||||
},
|
||||
"standby": {
|
||||
"name": "Standby"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"ata_unit": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -137,7 +137,7 @@ class TimeRemainingTrigger(Trigger):
|
||||
state = self._hass.states.get(entity_id)
|
||||
schedule_for_state(entity_id, state, state.context if state else None)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
self._hass,
|
||||
self._target,
|
||||
state_change_listener,
|
||||
|
||||
@@ -153,7 +153,7 @@ class ItemTriggerBase(Trigger, abc.ABC):
|
||||
functools.partial(self._handle_item_change, run_action=run_action),
|
||||
self._handle_entities_updated,
|
||||
)
|
||||
return listener.async_setup()
|
||||
return await listener.async_setup()
|
||||
|
||||
@callback
|
||||
@abc.abstractmethod
|
||||
|
||||
+2
-62
@@ -5,7 +5,7 @@ of entities and react to changes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import UserDict, defaultdict, deque
|
||||
from collections import UserDict, defaultdict
|
||||
from collections.abc import (
|
||||
Callable,
|
||||
Collection,
|
||||
@@ -1428,24 +1428,10 @@ def _verify_event_type_length_or_raise(event_type: EventType[_DataT] | str) -> N
|
||||
raise MaxLengthExceeded(event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE)
|
||||
|
||||
|
||||
# Maximum number of events event listeners may queue while a single top-level
|
||||
# event is being dispatched, to guard against event listeners firing events in
|
||||
# an endless loop.
|
||||
_MAX_QUEUED_EVENT_DISPATCHES: Final = 10_000
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Allow the firing of and listening for events."""
|
||||
|
||||
__slots__ = (
|
||||
"_debug",
|
||||
"_dispatching",
|
||||
"_event_queue",
|
||||
"_hass",
|
||||
"_listeners",
|
||||
"_match_all_listeners",
|
||||
"_queued_event_count",
|
||||
)
|
||||
__slots__ = ("_debug", "_hass", "_listeners", "_match_all_listeners")
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize a new event bus."""
|
||||
@@ -1455,11 +1441,6 @@ class EventBus:
|
||||
self._match_all_listeners: list[_FilterableJobType[Any]] = []
|
||||
self._listeners[MATCH_ALL] = self._match_all_listeners
|
||||
self._hass = hass
|
||||
self._event_queue: deque[
|
||||
tuple[EventType[Any] | str, Any, EventOrigin, Context | None, float]
|
||||
] = deque()
|
||||
self._dispatching = False
|
||||
self._queued_event_count = 0
|
||||
self._async_logging_changed()
|
||||
self.async_listen(EVENT_LOGGING_CHANGED, self._async_logging_changed)
|
||||
|
||||
@@ -1539,47 +1520,6 @@ class EventBus:
|
||||
"Bus:Handling %s", _event_repr(event_type, origin, event_data)
|
||||
)
|
||||
|
||||
if self._dispatching:
|
||||
# A nested fire is queued and dispatched after the current
|
||||
# dispatch. The fire time is captured now since dispatch is
|
||||
# deferred.
|
||||
if self._queued_event_count >= _MAX_QUEUED_EVENT_DISPATCHES:
|
||||
# Guard against event listeners firing events in an endless
|
||||
# loop: stop queuing further events and raise so the firing
|
||||
# listener's error handling kicks in. Events already queued
|
||||
# are still dispatched.
|
||||
raise HomeAssistantError(
|
||||
f"Event {event_type} not fired: more than"
|
||||
f" {_MAX_QUEUED_EVENT_DISPATCHES} events were queued by event"
|
||||
" listeners while dispatching a single event; event listeners"
|
||||
" are likely firing events in an endless loop"
|
||||
)
|
||||
self._queued_event_count += 1
|
||||
self._event_queue.append(
|
||||
(event_type, event_data, origin, context, time_fired or time.time())
|
||||
)
|
||||
return
|
||||
|
||||
self._dispatching = True
|
||||
self._queued_event_count = 0
|
||||
try:
|
||||
self._async_dispatch(event_type, event_data, origin, context, time_fired)
|
||||
event_queue = self._event_queue
|
||||
while event_queue:
|
||||
self._async_dispatch(*event_queue.popleft())
|
||||
finally:
|
||||
self._dispatching = False
|
||||
|
||||
@callback
|
||||
def _async_dispatch(
|
||||
self,
|
||||
event_type: EventType[_DataT] | str,
|
||||
event_data: _DataT | None,
|
||||
origin: EventOrigin,
|
||||
context: Context | None,
|
||||
time_fired: float | None,
|
||||
) -> None:
|
||||
"""Dispatch an event to its listeners."""
|
||||
listeners = self._listeners.get(event_type, EMPTY_LIST)
|
||||
if event_type not in EVENTS_EXCLUDED_FROM_MATCH_ALL:
|
||||
match_all_listeners = self._match_all_listeners
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Offer reusable conditions."""
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Container, Coroutine, Generator, Iterable, Mapping
|
||||
from contextlib import contextmanager
|
||||
@@ -56,7 +57,7 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
WEEKDAYS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
|
||||
from homeassistant.exceptions import (
|
||||
ConditionError,
|
||||
ConditionErrorContainer,
|
||||
@@ -87,6 +88,7 @@ from .automation import (
|
||||
move_options_fields_to_top_level,
|
||||
)
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .recorder import get_instance
|
||||
from .selector import (
|
||||
NumericThresholdMode,
|
||||
NumericThresholdSelector,
|
||||
@@ -119,6 +121,16 @@ VALIDATE_CONFIG_FORMAT = "{}_validate_config"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound on the best-effort recorder query used to prime `for:` durations
|
||||
# at setup. If history can't be read within this window we fall back to the
|
||||
# conservative live-state anchor rather than blocking condition setup.
|
||||
HISTORY_PRIMING_TIMEOUT = 10
|
||||
|
||||
# How far back the `for:` priming query reaches. Caps the cost of the query for
|
||||
# very long `for:` durations; beyond this we rely on the live-state anchor, so
|
||||
# such conditions may only become true once enough time has elapsed since setup.
|
||||
MAX_HISTORY_PRIMING_LOOKBACK = timedelta(hours=6)
|
||||
|
||||
_PLATFORM_ALIASES: dict[str | None, str | None] = {
|
||||
"and": None,
|
||||
"device": "device_automation",
|
||||
@@ -493,6 +505,11 @@ class EntityConditionBase(Condition):
|
||||
self._matcher = self._check_all_match_state
|
||||
self._on_unload: list[Callable[[], None]] = []
|
||||
self._valid_since: dict[str, datetime] = {}
|
||||
# Entities whose `for:` anchor is currently being resolved from recorder
|
||||
# history. While an entity is here the live listener leaves its anchor to
|
||||
# the priming, except that an invalidation removes it (the run broke, so
|
||||
# the in-flight history is stale and live tracking takes over).
|
||||
self._priming: set[str] = set()
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities matching any of the domain specs."""
|
||||
@@ -533,11 +550,19 @@ class EntityConditionBase(Condition):
|
||||
and self._should_include(_state)
|
||||
and self.is_valid_state(_state)
|
||||
):
|
||||
# While an entity is being primed from history, leave its anchor to
|
||||
# the priming: the entity stayed valid, so the run is unbroken and the
|
||||
# history start (which can be earlier than this update) is accurate.
|
||||
if entity_id in self._priming:
|
||||
return
|
||||
# Only record the time if not already tracked, to avoid
|
||||
# resetting the duration on unrelated state/attribute updates.
|
||||
if entity_id not in self._valid_since:
|
||||
self._valid_since[entity_id] = self._state_valid_since(_state)
|
||||
else:
|
||||
# An invalidation breaks the run, so any history being loaded for the
|
||||
# entity is now stale; stop priming it and let live tracking own it.
|
||||
self._priming.discard(entity_id)
|
||||
self._valid_since.pop(entity_id, None)
|
||||
|
||||
@override
|
||||
@@ -557,24 +582,150 @@ class EntityConditionBase(Condition):
|
||||
|
||||
self._update_valid_since(entity_id, to_state)
|
||||
|
||||
@callback
|
||||
def _on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
"""Handle changes to the tracked entity set."""
|
||||
for entity_id in added:
|
||||
self._update_valid_since(entity_id, self._hass.states.get(entity_id))
|
||||
for entity_id in removed:
|
||||
self._valid_since.pop(entity_id, None)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
self._hass,
|
||||
self._target,
|
||||
_state_change_listener,
|
||||
self.entity_filter,
|
||||
_on_entities_update,
|
||||
self._async_on_entities_update,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
self._on_unload.append(unsub)
|
||||
|
||||
async def _async_on_entities_update(
|
||||
self, added: set[str], removed: set[str]
|
||||
) -> None:
|
||||
"""Handle changes to the tracked entity set.
|
||||
|
||||
Removed entities stop being tracked immediately. Added entities are only
|
||||
considered by the condition once their `for:` anchor has been resolved
|
||||
(see `_async_prime_valid_since`); until then they are absent from
|
||||
`_valid_since`. The target tracker awaits this for the initial entity set
|
||||
at setup and runs it as a background task for later registry-driven
|
||||
changes.
|
||||
"""
|
||||
for entity_id in removed:
|
||||
self._priming.discard(entity_id)
|
||||
self._valid_since.pop(entity_id, None)
|
||||
await self._async_prime_valid_since(added)
|
||||
|
||||
async def _async_prime_valid_since(self, entity_ids: set[str]) -> None:
|
||||
"""Resolve and store the `for:` anchor for newly tracked entities.
|
||||
|
||||
For each currently-valid entity the anchor is the start of its current
|
||||
continuous run of validity, read from recorder history (bounded by
|
||||
`MAX_HISTORY_PRIMING_LOOKBACK`). The earlier of that and the current
|
||||
state's own anchor wins, so a run that began before the lookback window
|
||||
is not cut short. When the recorder is unavailable or the read fails,
|
||||
the current state's anchor is used alone. An entity is added to
|
||||
`_valid_since` only once this resolves, so a newly tracked entity does
|
||||
not participate in the condition until its anchor is known — rather than
|
||||
briefly using a conservative anchor that then changes.
|
||||
|
||||
While loading, an entity is held in `_priming`. A live change that keeps
|
||||
it valid is ignored (the run is unbroken, history is accurate), but an
|
||||
invalidation removes it from `_priming` so that we do not apply now-stale
|
||||
history over the live tracking that observed the break.
|
||||
"""
|
||||
# Conservative anchor from the live state for each currently-valid entity.
|
||||
anchors = {
|
||||
entity_id: self._state_valid_since(_state)
|
||||
for entity_id in entity_ids
|
||||
if (_state := self._hass.states.get(entity_id)) is not None
|
||||
and self._should_include(_state)
|
||||
and self.is_valid_state(_state)
|
||||
}
|
||||
if not anchors:
|
||||
return
|
||||
|
||||
self._priming.update(anchors)
|
||||
try:
|
||||
if "recorder" in self._hass.config.components:
|
||||
await self._async_refine_anchors_from_history(anchors)
|
||||
for entity_id, anchor in anchors.items():
|
||||
# Skip entities a live change invalidated mid-load: they were
|
||||
# removed from `_priming`, the run broke, and live tracking (which
|
||||
# saw the break) owns them — applying this history would be stale.
|
||||
if entity_id in self._priming:
|
||||
self._valid_since[entity_id] = anchor
|
||||
finally:
|
||||
self._priming.difference_update(anchors)
|
||||
|
||||
async def _async_refine_anchors_from_history(
|
||||
self, anchors: dict[str, datetime]
|
||||
) -> None:
|
||||
"""Move each anchor in `anchors` back to the true start of its run.
|
||||
|
||||
For each entity the anchor becomes the earlier of the recorded run start
|
||||
and the existing (live) anchor; entities with no usable history keep
|
||||
their existing anchor. Mutates `anchors` in place.
|
||||
"""
|
||||
from sqlalchemy.exc import SQLAlchemyError # noqa: PLC0415
|
||||
|
||||
from homeassistant.components.recorder import history # noqa: PLC0415
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self._duration is not None
|
||||
lookback = min(self._duration, MAX_HISTORY_PRIMING_LOOKBACK)
|
||||
start_time = dt_util.utcnow() - lookback
|
||||
instance = get_instance(self._hass)
|
||||
try:
|
||||
async with asyncio.timeout(HISTORY_PRIMING_TIMEOUT):
|
||||
# The history query only sees committed rows. Wait for the
|
||||
# recorder to flush its queue first.
|
||||
if (commit_future := instance.async_get_commit_future()) is not None:
|
||||
await commit_future
|
||||
historical_states = await instance.async_add_executor_job(
|
||||
ft.partial(
|
||||
history.get_significant_states,
|
||||
self._hass,
|
||||
start_time,
|
||||
entity_ids=list(anchors),
|
||||
include_start_time_state=True,
|
||||
# Mandatory: the default (True) drops attribute-only
|
||||
# changes for entities outside SIGNIFICANT_DOMAINS, which
|
||||
# are exactly the transitions attribute-based conditions
|
||||
# depend on.
|
||||
significant_changes_only=False,
|
||||
minimal_response=False,
|
||||
)
|
||||
)
|
||||
except (SQLAlchemyError, TimeoutError) as err:
|
||||
# Best effort: keep the conservative anchors rather than failing.
|
||||
_LOGGER.debug("Error priming condition durations from history: %s", err)
|
||||
return
|
||||
|
||||
for entity_id, rows in historical_states.items():
|
||||
valid_since = self._valid_since_from_history(
|
||||
entity_id, cast(list[State], rows)
|
||||
)
|
||||
if valid_since is not None:
|
||||
anchors[entity_id] = min(valid_since, anchors[entity_id])
|
||||
|
||||
def _valid_since_from_history(
|
||||
self, entity_id: str, rows: list[State]
|
||||
) -> datetime | None:
|
||||
"""Return when the current continuous run of valid states began.
|
||||
|
||||
Walks recorded states newest-first and stops at the first one that is
|
||||
not valid; the anchor is the oldest state in the unbroken run leading up
|
||||
to the latest recorded state. (We can't just take the first valid state
|
||||
in the window: an intervening invalid period breaks the run, so the
|
||||
anchor must come from after it.) Returns None when the latest recorded
|
||||
state is not valid, e.g. the recorder lags behind the live state machine.
|
||||
"""
|
||||
# Recorder rows are LazyState objects, which skip State.__init__ and so
|
||||
# never populate the domain/object_id that the validity checks rely on.
|
||||
domain, object_id = split_entity_id(entity_id)
|
||||
valid_since: datetime | None = None
|
||||
for _state in reversed(rows):
|
||||
_state.domain = domain
|
||||
_state.object_id = object_id
|
||||
if not (self._should_include(_state) and self.is_valid_state(_state)):
|
||||
break
|
||||
valid_since = self._state_valid_since(_state)
|
||||
return valid_since
|
||||
|
||||
@override
|
||||
def _async_unload(self) -> None:
|
||||
"""Unsubscribe from listeners."""
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Helpers for dealing with entity targets."""
|
||||
|
||||
import abc
|
||||
from collections.abc import Callable
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
import dataclasses
|
||||
import logging
|
||||
from logging import Logger
|
||||
@@ -292,7 +293,7 @@ class TargetEntityChangeTracker(abc.ABC):
|
||||
|
||||
self._registry_unsubs: list[CALLBACK_TYPE] = []
|
||||
|
||||
def async_setup(self) -> Callable[[], None]:
|
||||
async def async_setup(self) -> Callable[[], None]:
|
||||
"""Set up the state change tracking."""
|
||||
self._setup_registry_listeners()
|
||||
self._handle_target_update()
|
||||
@@ -304,18 +305,20 @@ class TargetEntityChangeTracker(abc.ABC):
|
||||
"""Called when there's an update to tracked target entities."""
|
||||
|
||||
@callback
|
||||
def _handle_target_update(self, event: Event[Any] | None = None) -> None:
|
||||
"""Handle updates in the tracked targets."""
|
||||
def _referenced_entities(self) -> set[str]:
|
||||
"""Return the currently tracked, filtered entity ids."""
|
||||
selected = async_extract_referenced_entity_ids(
|
||||
self._hass,
|
||||
self._target_selection,
|
||||
expand_group=False,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
filtered_entities = self._entity_filter(
|
||||
selected.referenced | selected.indirectly_referenced
|
||||
)
|
||||
self._handle_entities_update(filtered_entities)
|
||||
return self._entity_filter(selected.referenced | selected.indirectly_referenced)
|
||||
|
||||
@callback
|
||||
def _handle_target_update(self, event: Event[Any] | None = None) -> None:
|
||||
"""Handle updates in the tracked targets."""
|
||||
self._handle_entities_update(self._referenced_entities())
|
||||
|
||||
def _setup_registry_listeners(self) -> None:
|
||||
"""Set up listeners for registry changes that require resubscription."""
|
||||
@@ -356,11 +359,20 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
target_selection: TargetSelection,
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]],
|
||||
on_entities_update: Callable[[set[str], set[str]], None] | None = None,
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str]], Coroutine[Any, Any, None] | None
|
||||
]
|
||||
| None = None,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the state change tracker."""
|
||||
"""Initialize the state change tracker.
|
||||
|
||||
`on_entities_update` may be a plain callback or a coroutine function.
|
||||
A coroutine is awaited for the initial entity set (so setup is
|
||||
deterministic) and scheduled as a background task for later
|
||||
registry-driven changes.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
target_selection,
|
||||
@@ -371,17 +383,47 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
self._on_entities_update = on_entities_update
|
||||
self._state_change_unsub: CALLBACK_TYPE | None = None
|
||||
self._tracked_entities: set[str] = set()
|
||||
self._update_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
async def async_setup(self) -> Callable[[], None]:
|
||||
"""Set up tracking, awaiting the update for the initial entity set.
|
||||
|
||||
The initial update is awaited so that a coroutine `on_entities_update`
|
||||
(e.g. one that loads history) completes before setup returns. Later
|
||||
registry-driven updates instead arrive via the callback
|
||||
`_handle_entities_update` and are scheduled as background tasks.
|
||||
"""
|
||||
self._setup_registry_listeners()
|
||||
entities = self._referenced_entities()
|
||||
if (coro := self._apply_entities_update(entities)) is not None:
|
||||
await coro
|
||||
return self._unsubscribe
|
||||
|
||||
@callback
|
||||
def _handle_entities_update(self, tracked_entities: set[str]) -> None:
|
||||
"""Handle the tracked entities."""
|
||||
"""Handle a registry-driven change to the tracked entity set."""
|
||||
if (coro := self._apply_entities_update(tracked_entities)) is None:
|
||||
return
|
||||
# Tracked so it can be cancelled on unsubscribe.
|
||||
task = self._hass.async_create_background_task(
|
||||
coro, "Target entity tracker update"
|
||||
)
|
||||
self._update_tasks.add(task)
|
||||
task.add_done_callback(self._update_tasks.discard)
|
||||
|
||||
def _apply_entities_update(
|
||||
self, tracked_entities: set[str]
|
||||
) -> Coroutine[Any, Any, None] | None:
|
||||
"""Resubscribe to state changes; return the update coroutine, if any."""
|
||||
previous_entities = self._tracked_entities
|
||||
self._tracked_entities = tracked_entities
|
||||
|
||||
result: Coroutine[Any, Any, None] | None = None
|
||||
if self._on_entities_update is not None:
|
||||
added = tracked_entities - previous_entities
|
||||
removed = previous_entities - tracked_entities
|
||||
if added or removed:
|
||||
self._on_entities_update(added, removed)
|
||||
result = self._on_entities_update(added, removed)
|
||||
|
||||
@callback
|
||||
def state_change_listener(event: Event[EventStateChangedData]) -> None:
|
||||
@@ -395,6 +437,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
self._state_change_unsub = async_track_state_change_event(
|
||||
self._hass, tracked_entities, state_change_listener
|
||||
)
|
||||
return result
|
||||
|
||||
def _unsubscribe(self) -> None:
|
||||
"""Unsubscribe from all events."""
|
||||
@@ -402,14 +445,18 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
if self._state_change_unsub:
|
||||
self._state_change_unsub()
|
||||
self._state_change_unsub = None
|
||||
for task in self._update_tasks:
|
||||
task.cancel()
|
||||
self._update_tasks.clear()
|
||||
|
||||
|
||||
def async_track_target_selector_state_change_event(
|
||||
async def async_track_target_selector_state_change_event(
|
||||
hass: HomeAssistant,
|
||||
target_selector_config: ConfigType,
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]] = lambda x: x,
|
||||
on_entities_update: Callable[[set[str], set[str]], None] | None = None,
|
||||
on_entities_update: Callable[[set[str], set[str]], Coroutine[Any, Any, None] | None]
|
||||
| None = None,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
) -> CALLBACK_TYPE:
|
||||
@@ -419,6 +466,10 @@ def async_track_target_selector_state_change_event(
|
||||
When `primary_entities_only` is True, indirect target
|
||||
expansion (via device, area, and floor) skips entities
|
||||
with an `entity_category` (config or diagnostic entities).
|
||||
|
||||
`on_entities_update` may be a coroutine function; it is awaited for the
|
||||
initial entity set and scheduled as a task for later registry-driven
|
||||
changes, so this function must itself be awaited.
|
||||
"""
|
||||
target_selection = TargetSelection(target_selector_config)
|
||||
if not target_selection.has_any_target:
|
||||
@@ -435,4 +486,4 @@ def async_track_target_selector_state_change_event(
|
||||
on_entities_update,
|
||||
primary_entities_only=primary_entities_only,
|
||||
)
|
||||
return tracker.async_setup()
|
||||
return await tracker.async_setup()
|
||||
|
||||
@@ -579,7 +579,7 @@ class EntityTriggerBase(Trigger):
|
||||
),
|
||||
)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
self._hass,
|
||||
self._target,
|
||||
state_change_listener,
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==6.8.3
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.7.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
home-assistant-frontend==20260527.5
|
||||
home-assistant-frontend==20260527.6
|
||||
home-assistant-intents==2026.6.1
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
|
||||
|
||||
from typing import Final
|
||||
|
||||
FRONTEND_VERSION: Final[str] = "20260527.5"
|
||||
FRONTEND_VERSION: Final[str] = "20260527.6"
|
||||
|
||||
MDI_ICONS: Final[set[str]] = {
|
||||
"ab-testing",
|
||||
|
||||
Generated
+3
-3
@@ -724,7 +724,7 @@ brottsplatskartan==1.0.5
|
||||
brunt==1.2.0
|
||||
|
||||
# homeassistant.components.bthome
|
||||
bthome-ble==3.23.2
|
||||
bthome-ble==3.23.4
|
||||
|
||||
# homeassistant.components.bt_home_hub_5
|
||||
bthomehub5-devicelist==0.1.1
|
||||
@@ -1272,7 +1272,7 @@ hole==0.9.0
|
||||
holidays==0.98
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260527.5
|
||||
home-assistant-frontend==20260527.6
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.6.1
|
||||
@@ -1610,7 +1610,7 @@ motionblindsble==0.1.3
|
||||
motioneye-client==0.3.14
|
||||
|
||||
# homeassistant.components.bang_olufsen
|
||||
mozart-api==5.3.1.108.2
|
||||
mozart-api==6.2.0.44.0
|
||||
|
||||
# homeassistant.components.mullvad
|
||||
mullvad-api==1.0.0
|
||||
|
||||
@@ -25,6 +25,7 @@ class CheckKind(StrEnum):
|
||||
REPO_PUBLIC = "repo_public"
|
||||
CI_UPLOAD = "ci_upload"
|
||||
RELEASE_PIPELINE = "release_pipeline"
|
||||
SECURITY = "security"
|
||||
PR_LINK = "pr_link"
|
||||
ASYNC_BLOCKING = "async_blocking"
|
||||
YANKED = "yanked"
|
||||
|
||||
@@ -21,6 +21,7 @@ _CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
|
||||
(CheckKind.REPO_PUBLIC, "Repo Public"),
|
||||
(CheckKind.CI_UPLOAD, "CI Upload"),
|
||||
(CheckKind.RELEASE_PIPELINE, "Release Pipeline"),
|
||||
(CheckKind.SECURITY, "Security"),
|
||||
(CheckKind.PR_LINK, "PR Link"),
|
||||
(CheckKind.ASYNC_BLOCKING, "Async Safe"),
|
||||
)
|
||||
|
||||
@@ -16,6 +16,8 @@ What the runner defers to the LLM (NEEDS_AGENT):
|
||||
- `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.
|
||||
- `security`: lightweight scan of the upstream source for supply-chain red
|
||||
flags. Always deferred — the agent fetches the source and inspects it.
|
||||
"""
|
||||
|
||||
from .diff import parse_diff
|
||||
@@ -137,6 +139,7 @@ def run_checks(
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = fail
|
||||
pkg.checks[CheckKind.PR_LINK] = fail
|
||||
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
|
||||
pkg.checks[CheckKind.SECURITY] = fail
|
||||
elif pkg.repo_url:
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = CheckResult(
|
||||
CheckStatus.NEEDS_AGENT,
|
||||
@@ -146,6 +149,10 @@ def run_checks(
|
||||
CheckStatus.NEEDS_AGENT,
|
||||
"Presence of the required link in the PR description must be verified by the agent.",
|
||||
)
|
||||
pkg.checks[CheckKind.SECURITY] = CheckResult(
|
||||
CheckStatus.NEEDS_AGENT,
|
||||
"Baseline supply-chain source scan must be performed by the agent.",
|
||||
)
|
||||
if pkg.old_version is None:
|
||||
async_reason = (
|
||||
"New dependency: agent must review the entire source tree "
|
||||
@@ -168,6 +175,10 @@ def run_checks(
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = fail
|
||||
pkg.checks[CheckKind.PR_LINK] = fail
|
||||
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
|
||||
pkg.checks[CheckKind.SECURITY] = CheckResult(
|
||||
CheckStatus.FAIL,
|
||||
"No source repository URL on PyPI — source cannot be inspected.",
|
||||
)
|
||||
result = CheckRunResult(pr_number=pr_number, packages=packages)
|
||||
result.rendered_comment = render_comment(result)
|
||||
return result
|
||||
|
||||
@@ -85,6 +85,7 @@ async def test_sensors_aranet_radiation(
|
||||
assert device.model == "Aranet Radiation"
|
||||
assert device.sw_version == "v1.4.38"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -146,6 +147,7 @@ async def test_sensors_aranet2(
|
||||
assert device.model == "Aranet2"
|
||||
assert device.sw_version == "v1.4.4"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -227,6 +229,7 @@ async def test_sensors_aranet4(
|
||||
assert device.model == "Aranet4"
|
||||
assert device.sw_version == "v1.2.0"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -310,6 +313,7 @@ async def test_sensors_aranetrn(
|
||||
assert device.model == "Aranet Radon"
|
||||
assert device.sw_version == "v1.6.4"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from uuid import UUID
|
||||
|
||||
from mozart_api.models import (
|
||||
Action,
|
||||
@@ -254,8 +255,8 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
dynamic_list=None,
|
||||
first_child_menu_item_id=None,
|
||||
label="Yle Radio Suomi Helsinki",
|
||||
next_sibling_menu_item_id="0b4552f8-7ac6-5046-9d44-5410a815b8d6",
|
||||
parent_menu_item_id="eee0c2d0-2b3a-4899-a708-658475c38926",
|
||||
next_sibling_menu_item_id=UUID("0b4552f8-7ac6-5046-9d44-5410a815b8d6"),
|
||||
parent_menu_item_id=UUID("eee0c2d0-2b3a-4899-a708-658475c38926"),
|
||||
available=None,
|
||||
content=ContentItem(
|
||||
categories=["music"],
|
||||
@@ -264,7 +265,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
source=SourceTypeEnum(value="netRadio"),
|
||||
),
|
||||
fixed=True,
|
||||
id="b355888b-2cde-5f94-8592-d47b71d52a27",
|
||||
id=UUID("b355888b-2cde-5f94-8592-d47b71d52a27"),
|
||||
),
|
||||
# Has "hdmi" as category, so should be included in video sources
|
||||
"b6591565-80f4-4356-bcd9-c92ca247f0a9": RemoteMenuItem(
|
||||
@@ -293,8 +294,8 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
dynamic_list="none",
|
||||
first_child_menu_item_id=None,
|
||||
label="HDMI A",
|
||||
next_sibling_menu_item_id="0ba98974-7b1f-40dc-bc48-fbacbb0f1793",
|
||||
parent_menu_item_id="b66c835b-6b98-4400-8f84-6348043792c7",
|
||||
next_sibling_menu_item_id=UUID("0ba98974-7b1f-40dc-bc48-fbacbb0f1793"),
|
||||
parent_menu_item_id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"),
|
||||
available=True,
|
||||
content=ContentItem(
|
||||
categories=["hdmi"],
|
||||
@@ -303,7 +304,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
source=SourceTypeEnum(value="tv"),
|
||||
),
|
||||
fixed=False,
|
||||
id="b6591565-80f4-4356-bcd9-c92ca247f0a9",
|
||||
id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"),
|
||||
),
|
||||
# The parent remote menu item. Has the TV label and
|
||||
# should therefore not be included in video sources
|
||||
@@ -312,14 +313,14 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
scene_list=None,
|
||||
disabled=False,
|
||||
dynamic_list="none",
|
||||
first_child_menu_item_id="b6591565-80f4-4356-bcd9-c92ca247f0a9",
|
||||
first_child_menu_item_id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"),
|
||||
label="TV",
|
||||
next_sibling_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb",
|
||||
next_sibling_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"),
|
||||
parent_menu_item_id=None,
|
||||
available=True,
|
||||
content=None,
|
||||
fixed=True,
|
||||
id="b66c835b-6b98-4400-8f84-6348043792c7",
|
||||
id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"),
|
||||
),
|
||||
# Has an empty content, so should not be included
|
||||
"64c9da45-3682-44a4-8030-09ed3ef44160": RemoteMenuItem(
|
||||
@@ -330,11 +331,11 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
first_child_menu_item_id=None,
|
||||
label="ListeningPosition",
|
||||
next_sibling_menu_item_id=None,
|
||||
parent_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb",
|
||||
parent_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"),
|
||||
available=True,
|
||||
content=None,
|
||||
fixed=True,
|
||||
id="64c9da45-3682-44a4-8030-09ed3ef44160",
|
||||
id=UUID("64c9da45-3682-44a4-8030-09ed3ef44160"),
|
||||
),
|
||||
}
|
||||
client.get_beolink_peers = AsyncMock()
|
||||
@@ -343,11 +344,13 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
friendly_name=TEST_FRIENDLY_NAME_3,
|
||||
jid=TEST_JID_3,
|
||||
ip_address=TEST_HOST_3,
|
||||
audio_transport="v2",
|
||||
),
|
||||
BeolinkPeer(
|
||||
friendly_name=TEST_FRIENDLY_NAME_4,
|
||||
jid=TEST_JID_4,
|
||||
ip_address=TEST_HOST_4,
|
||||
audio_transport="v2",
|
||||
),
|
||||
]
|
||||
client.get_beolink_listeners = AsyncMock()
|
||||
@@ -356,11 +359,13 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
friendly_name=TEST_FRIENDLY_NAME_3,
|
||||
jid=TEST_JID_3,
|
||||
ip_address=TEST_HOST_3,
|
||||
audio_transport="v2",
|
||||
),
|
||||
BeolinkPeer(
|
||||
friendly_name=TEST_FRIENDLY_NAME_4,
|
||||
jid=TEST_JID_4,
|
||||
ip_address=TEST_HOST_4,
|
||||
audio_transport="v2",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ TEST_PLAYBACK_METADATA_VIDEO = PlaybackContentMetadata(
|
||||
title="HDMI A",
|
||||
source_internal_id="hdmi_1",
|
||||
output_channel_processing="TrueImage",
|
||||
output_Channels="5.0.2",
|
||||
output_channels="5.0.2",
|
||||
)
|
||||
TEST_PLAYBACK_ERROR = PlaybackError(error="Test error")
|
||||
TEST_PLAYBACK_PROGRESS = PlaybackProgress(progress=123)
|
||||
|
||||
@@ -582,7 +582,7 @@ async def test_async_update_beolink_listener(
|
||||
playback_metadata_callback(
|
||||
PlaybackContentMetadata(
|
||||
remote_leader=BeolinkLeader(
|
||||
friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2
|
||||
friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2, audio_transport="v2"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -45,23 +45,75 @@ def open_sensor_fixture() -> tuple[AsyncMock, str]:
|
||||
return feature, "binary_sensor.my_open_sensor_opensensor_0_open"
|
||||
|
||||
|
||||
@pytest.fixture(name="inputsensor")
|
||||
def inputsensor_fixture() -> tuple[AsyncMock, str]:
|
||||
"""Return a default inputSensor fixture."""
|
||||
feature: AsyncMock = mock_feature(
|
||||
"binary_sensors",
|
||||
blebox_uniapi.binary_sensor.Input,
|
||||
unique_id="BleBox-inputSensorD-aa11bb22cc33-0.input",
|
||||
full_name="inputSensorD-0.input",
|
||||
device_class="input",
|
||||
)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My input sensor")
|
||||
type(product).model = PropertyMock(return_value="inputSensorD")
|
||||
return feature, "binary_sensor.my_input_sensor_inputsensord_0_input"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"fixture_name",
|
||||
"unique_id",
|
||||
"expected_name",
|
||||
"expected_device_class",
|
||||
"expected_state",
|
||||
"expected_device_name",
|
||||
),
|
||||
[
|
||||
pytest.param(
|
||||
"rainsensor",
|
||||
"BleBox-windRainSensor-ea68e74f4f49-0.rain",
|
||||
"My rain sensor windRainSensor-0.rain",
|
||||
BinarySensorDeviceClass.MOISTURE,
|
||||
STATE_ON,
|
||||
"My rain sensor",
|
||||
id="moisture",
|
||||
),
|
||||
pytest.param(
|
||||
"inputsensor",
|
||||
"BleBox-inputSensorD-aa11bb22cc33-0.input",
|
||||
"My input sensor inputSensorD-0.input",
|
||||
None,
|
||||
STATE_ON,
|
||||
"My input sensor",
|
||||
id="input",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_init(
|
||||
rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant
|
||||
hass: HomeAssistant,
|
||||
fixture_name: str,
|
||||
unique_id: str,
|
||||
expected_name: str,
|
||||
expected_device_class: BinarySensorDeviceClass | None,
|
||||
expected_state: str,
|
||||
expected_device_name: str,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
"""Test binary_sensor initialisation."""
|
||||
_, entity_id = rainsensor
|
||||
_, entity_id = request.getfixturevalue(fixture_name)
|
||||
entry = await async_setup_entity(hass, entity_id)
|
||||
assert entry.unique_id == "BleBox-windRainSensor-ea68e74f4f49-0.rain"
|
||||
assert entry.unique_id == unique_id
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My rain sensor windRainSensor-0.rain"
|
||||
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE
|
||||
assert state.state == STATE_ON
|
||||
assert state.name == expected_name
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == expected_device_class
|
||||
assert state.state == expected_state
|
||||
|
||||
device = device_registry.async_get(entry.device_id)
|
||||
|
||||
assert device.name == "My rain sensor"
|
||||
assert device.name == expected_device_name
|
||||
|
||||
|
||||
async def test_open_sensor_init(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# serializer version: 1
|
||||
# name: test_diagnostics
|
||||
dict({
|
||||
'device': dict({
|
||||
'support_advanced_modes': False,
|
||||
'support_away_mode': False,
|
||||
'support_compressor_frequency': False,
|
||||
'support_energy_consumption': False,
|
||||
'support_fan_rate': False,
|
||||
'support_humidity': False,
|
||||
'support_outside_temperature': False,
|
||||
'support_swing_mode': False,
|
||||
'values': dict({
|
||||
'lztemp_c': '22',
|
||||
'lztemp_h': '22',
|
||||
'model': 'TESTMODEL',
|
||||
'name': 'Daikin Test',
|
||||
'ver': '1_0_0',
|
||||
'zone_name': 'Living',
|
||||
'zone_onoff': '1',
|
||||
}),
|
||||
}),
|
||||
'entry_data': dict({
|
||||
'host': '**REDACTED**',
|
||||
'mac': '**REDACTED**',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Tests for the diagnostics data provided by the Daikin integration."""
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.daikin.const import KEY_MAC
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .test_config_flow import HOST, MAC
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def test_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
zone_device,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
entry = MockConfigEntry(
|
||||
domain="daikin",
|
||||
unique_id=MAC,
|
||||
data={CONF_HOST: HOST, KEY_MAC: MAC},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot
|
||||
@@ -157,12 +157,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
|
||||
history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids()
|
||||
)
|
||||
|
||||
# Check we got the expected events: the helper entity's device link is
|
||||
# cleared when the source device is removed (the helper entity belongs to
|
||||
# the history_stats config entry, not the removed source config entry),
|
||||
# then the helper entity is removed when the history_stats config entry is
|
||||
# removed. Both registry actions are observed in fire order.
|
||||
assert events == ["update", "remove"]
|
||||
# Check we got the expected events
|
||||
assert events == ["remove"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("recorder_mock")
|
||||
|
||||
@@ -74,6 +74,7 @@ async def test_humanify_homekit_changed_event(hass: HomeAssistant, hk_driver) ->
|
||||
async def test_bridge_with_triggers(
|
||||
hass: HomeAssistant,
|
||||
hk_driver,
|
||||
demo_cleanup,
|
||||
entity_registry: er.EntityRegistry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
|
||||
@@ -240,7 +240,7 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) -
|
||||
|
||||
assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await hass.services.async_call(
|
||||
alarm_control_panel.DOMAIN,
|
||||
service,
|
||||
@@ -250,6 +250,7 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) -
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
assert err.value.translation_key == "invalid_code"
|
||||
|
||||
assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED
|
||||
|
||||
@@ -1108,8 +1109,9 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N
|
||||
|
||||
assert hass.states.get(entity_id).state == AlarmControlPanelState.PENDING
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await common.async_alarm_disarm(hass, entity_id=entity_id)
|
||||
assert err.value.translation_key == "invalid_code"
|
||||
|
||||
assert hass.states.get(entity_id).state == AlarmControlPanelState.PENDING
|
||||
|
||||
@@ -1226,8 +1228,9 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == AlarmControlPanelState.ARMED_HOME
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await common.async_alarm_disarm(hass, "def")
|
||||
assert err.value.translation_key == "invalid_code"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == AlarmControlPanelState.ARMED_HOME
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_entities[binary_sensor.heat_pump_error-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.heat_pump_error',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Error',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Error',
|
||||
'platform': 'melcloud_home',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'error',
|
||||
'unique_id': 'atw-unit-uuid-1_error',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.heat_pump_error-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'problem',
|
||||
'friendly_name': 'Heat Pump Error',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.heat_pump_error',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.heat_pump_forced_hot_water-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.heat_pump_forced_hot_water',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Forced hot water',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Forced hot water',
|
||||
'platform': 'melcloud_home',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'forced_hot_water',
|
||||
'unique_id': 'atw-unit-uuid-1_forced_hot_water',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.heat_pump_forced_hot_water-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Heat Pump Forced hot water',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.heat_pump_forced_hot_water',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.heat_pump_standby-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.heat_pump_standby',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Standby',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Standby',
|
||||
'platform': 'melcloud_home',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'standby',
|
||||
'unique_id': 'atw-unit-uuid-1_standby',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.heat_pump_standby-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Heat Pump Standby',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.heat_pump_standby',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.living_room_ac_error-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.living_room_ac_error',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Error',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Error',
|
||||
'platform': 'melcloud_home',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'error',
|
||||
'unique_id': 'ata-unit-uuid-1_error',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.living_room_ac_error-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'problem',
|
||||
'friendly_name': 'Living Room AC Error',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.living_room_ac_error',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.living_room_ac_standby-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.living_room_ac_standby',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Standby',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Standby',
|
||||
'platform': 'melcloud_home',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'standby',
|
||||
'unique_id': 'ata-unit-uuid-1_standby',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.living_room_ac_standby-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Living Room AC Standby',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.living_room_ac_standby',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for the MELCloud Home binary sensor platform."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
ATA_ERROR_ENTITY_ID = "binary_sensor.living_room_ac_error"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
|
||||
"""Make sure all entities are enabled."""
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_melcloud_client")
|
||||
async def test_all_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test all entities."""
|
||||
with patch(
|
||||
"homeassistant.components.melcloud_home.PLATFORMS",
|
||||
[Platform.BINARY_SENSOR],
|
||||
):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, mock_config_entry.entry_id
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test the MELCloud Home climate platform."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiomelcloudhome import (
|
||||
ATAFanSpeed,
|
||||
@@ -29,6 +29,7 @@ from homeassistant.const import (
|
||||
ATTR_TEMPERATURE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -50,8 +51,14 @@ async def test_climate_platform(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test all climate entity states and attributes from fixture data."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
with patch(
|
||||
"homeassistant.components.melcloud_home.PLATFORMS",
|
||||
[Platform.CLIMATE],
|
||||
):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, mock_config_entry.entry_id
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -145,12 +145,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
|
||||
# Check that the statistics config entry is removed
|
||||
assert statistics_config_entry.entry_id not in hass.config_entries.async_entry_ids()
|
||||
|
||||
# Check we got the expected events: the helper entity's device link is
|
||||
# cleared when the source device is removed (the helper entity belongs to
|
||||
# the statistics config entry, not the removed source config entry), then
|
||||
# the helper entity is removed when the statistics config entry is removed.
|
||||
# Both registry actions are observed in fire order.
|
||||
assert events == ["update", "remove"]
|
||||
# Check we got the expected events
|
||||
assert events == ["remove"]
|
||||
|
||||
|
||||
async def test_async_handle_source_entity_changes_source_entity_removed_shared_device(
|
||||
|
||||
@@ -177,12 +177,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
|
||||
# Check that the trend config entry is removed
|
||||
assert trend_config_entry.entry_id not in hass.config_entries.async_entry_ids()
|
||||
|
||||
# Check we got the expected events: the helper entity's device link is
|
||||
# cleared when the source device is removed (the helper entity belongs to
|
||||
# the trend config entry, not the removed source config entry), then the
|
||||
# helper entity is removed when the trend config entry is removed. Both
|
||||
# registry actions are observed in fire order.
|
||||
assert events == ["update", "remove"]
|
||||
# Check we got the expected events
|
||||
assert events == ["remove"]
|
||||
|
||||
|
||||
async def test_async_handle_source_entity_changes_source_entity_removed_shared_device(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test the condition helper."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
from dataclasses import dataclass, field
|
||||
@@ -13,12 +14,14 @@ from freezegun import freeze_time
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from pytest_unordered import unordered
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_automation import (
|
||||
DOMAIN as DEVICE_AUTOMATION_DOMAIN,
|
||||
)
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.components.recorder import Recorder, get_instance, history
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.components.sun import DOMAIN as SUN_DOMAIN
|
||||
from homeassistant.components.system_health import DOMAIN as SYSTEM_HEALTH_DOMAIN
|
||||
@@ -60,6 +63,7 @@ from homeassistant.helpers.condition import (
|
||||
BEHAVIOR_ALL,
|
||||
BEHAVIOR_ANY,
|
||||
CONDITIONS,
|
||||
MAX_HISTORY_PRIMING_LOOKBACK,
|
||||
Condition,
|
||||
ConditionChecker,
|
||||
EntityConditionBase,
|
||||
@@ -79,6 +83,7 @@ from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
from homeassistant.util.yaml.loader import parse_yaml
|
||||
|
||||
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
|
||||
from tests.components.recorder.common import async_wait_recording_done
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@@ -5074,12 +5079,15 @@ async def test_state_condition_attr_duration_initial_state(
|
||||
duration: int,
|
||||
initially_met: bool,
|
||||
) -> None:
|
||||
"""Test attribute-based condition initialization from existing state.
|
||||
"""Test attribute-based condition initialization without a recorder.
|
||||
|
||||
The condition uses last_updated (not last_changed) to determine how long
|
||||
an attribute-based condition has been true. This is conservative: when
|
||||
With no recorder available the condition falls back to anchoring `for:`
|
||||
durations to the current state's last_updated. This is conservative: when
|
||||
the main state changes but the tracked attribute stays the same,
|
||||
last_updated is bumped and the effective duration resets.
|
||||
last_updated is bumped and the effective duration resets (see the
|
||||
`state_change_bumps_last_updated_not_met` case). The recorder-backed
|
||||
variant in test_state_condition_attr_duration_initial_state_from_history
|
||||
refines this from real history.
|
||||
"""
|
||||
for step in steps:
|
||||
freezer.tick(timedelta(seconds=step.delay_before))
|
||||
@@ -5313,6 +5321,611 @@ async def test_state_condition_attr_duration_unrelated_attr_update(
|
||||
assert test.async_check() is True
|
||||
|
||||
|
||||
async def _record_attr_steps(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
start: datetime,
|
||||
entity_id: str,
|
||||
steps: list[_AttrInitStep],
|
||||
) -> int:
|
||||
"""Record a series of state writes into the recorder at controlled times.
|
||||
|
||||
Returns the number of seconds elapsed from `start` to the final write.
|
||||
"""
|
||||
elapsed = 0
|
||||
for step in steps:
|
||||
elapsed += step.delay_before
|
||||
freezer.move_to(start + timedelta(seconds=elapsed))
|
||||
hass.states.async_set(entity_id, step.state, step.attrs)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
return elapsed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("steps", "wait_before_setup", "initially_met"),
|
||||
[
|
||||
# Valid the entire time → met (10s >= 5s).
|
||||
([_AttrInitStep(STATE_ON, {"test_attr": True})], 10, True),
|
||||
# Valid for less than the `for:` window → not met (3s < 5s).
|
||||
([_AttrInitStep(STATE_ON, {"test_attr": True})], 3, False),
|
||||
# The tracked attribute stayed valid across an unrelated main-state
|
||||
# change. The OFF write bumps last_updated, but history shows the
|
||||
# attribute never left the valid range → met. This is exactly the case
|
||||
# the conservative last_updated anchor reports wrong (it returns False;
|
||||
# see test_state_condition_attr_duration_initial_state).
|
||||
(
|
||||
[
|
||||
_AttrInitStep(STATE_ON, {"test_attr": True}),
|
||||
_AttrInitStep(STATE_OFF, {"test_attr": True}, delay_before=8),
|
||||
],
|
||||
2,
|
||||
True,
|
||||
),
|
||||
# Invalid, then valid 6s before setup → met (6s >= 5s).
|
||||
(
|
||||
[
|
||||
_AttrInitStep(STATE_ON, {"test_attr": False}),
|
||||
_AttrInitStep(STATE_ON, {"test_attr": True}, delay_before=4),
|
||||
],
|
||||
6,
|
||||
True,
|
||||
),
|
||||
# Invalid, then valid only 4s before setup → not met (4s < 5s).
|
||||
(
|
||||
[
|
||||
_AttrInitStep(STATE_ON, {"test_attr": False}),
|
||||
_AttrInitStep(STATE_ON, {"test_attr": True}, delay_before=6),
|
||||
],
|
||||
4,
|
||||
False,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"valid_long_enough",
|
||||
"valid_too_short",
|
||||
"valid_across_state_change",
|
||||
"invalid_then_valid_met",
|
||||
"invalid_then_valid_not_met",
|
||||
],
|
||||
)
|
||||
async def test_state_condition_attr_duration_initial_state_from_history(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
steps: list[_AttrInitStep],
|
||||
wait_before_setup: int,
|
||||
initially_met: bool,
|
||||
) -> None:
|
||||
"""Test attribute-based `for:` priming from recorder history.
|
||||
|
||||
With the recorder available, the condition walks recent history to find
|
||||
when the tracked value actually entered its current continuous valid run,
|
||||
rather than conservatively anchoring to the current state's last_updated.
|
||||
The `valid_across_state_change` case is the key improvement: an unrelated
|
||||
main-state change no longer resets the duration.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
elapsed = await _record_attr_steps(hass, freezer, start, entity_id, steps)
|
||||
|
||||
freezer.move_to(start + timedelta(seconds=elapsed + wait_before_setup))
|
||||
test = await _setup_attr_state_condition(
|
||||
hass,
|
||||
entity_ids=entity_id,
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
assert test.async_check() is initially_met
|
||||
|
||||
|
||||
async def test_state_condition_attr_duration_history_includes_attr_only_changes(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Attribute-only invalidations inside the window must reset the timer.
|
||||
|
||||
The tracked value dips invalid and recovers through attribute-only changes
|
||||
(the main state stays ON throughout). Those rows are only returned when the
|
||||
history query passes significant_changes_only=False; were they dropped, the
|
||||
window would look continuously valid and the condition would wrongly report
|
||||
the `for:` duration as met.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
start = dt_util.utcnow()
|
||||
steps = [
|
||||
_AttrInitStep(STATE_ON, {"test_attr": True}),
|
||||
_AttrInitStep(STATE_ON, {"test_attr": False}, delay_before=6),
|
||||
_AttrInitStep(STATE_ON, {"test_attr": True}, delay_before=2),
|
||||
]
|
||||
with freeze_time(start) as freezer:
|
||||
elapsed = await _record_attr_steps(hass, freezer, start, entity_id, steps)
|
||||
|
||||
freezer.move_to(start + timedelta(seconds=elapsed + 2))
|
||||
test = await _setup_attr_state_condition(
|
||||
hass,
|
||||
entity_ids=entity_id,
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
# Valid only for the last 2s (since the recovery at t=8); the dip to
|
||||
# invalid at t=6 falls inside the 5s window → not met.
|
||||
assert test.async_check() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("behavior", "expected"),
|
||||
[(BEHAVIOR_ANY, True), (BEHAVIOR_ALL, False)],
|
||||
)
|
||||
async def test_state_condition_attr_duration_from_history_multiple_entities(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
behavior: str,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""History priming covers every targeted entity in a single query.
|
||||
|
||||
entity_1 has been valid long enough; entity_2 only recently became valid,
|
||||
so `any` passes while `all` does not.
|
||||
"""
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": True})
|
||||
hass.states.async_set("test.entity_2", STATE_ON, {"test_attr": False})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
freezer.move_to(start + timedelta(seconds=7))
|
||||
hass.states.async_set("test.entity_2", STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
freezer.move_to(start + timedelta(seconds=10))
|
||||
test = await _setup_attr_state_condition(
|
||||
hass,
|
||||
entity_ids=["test.entity_1", "test.entity_2"],
|
||||
states={True},
|
||||
condition_options={ATTR_BEHAVIOR: behavior, CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
# entity_1 valid for 10s (met); entity_2 valid for only 3s (not met).
|
||||
assert test.async_check() is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"history_error",
|
||||
[SQLAlchemyError("boom"), TimeoutError()],
|
||||
ids=["db_error", "timeout"],
|
||||
)
|
||||
async def test_state_condition_attr_duration_history_error_falls_back(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
history_error: Exception,
|
||||
) -> None:
|
||||
"""A failing/slow history query must not break setup; it falls back.
|
||||
|
||||
The tracked attribute stayed valid across an unrelated main-state change,
|
||||
so a successful history read would report the duration as met. When the
|
||||
query errors or times out, the condition keeps the conservative
|
||||
last_updated anchor (set when the tracker was wired up) instead, which here
|
||||
reports not met — and crucially, setup does not raise.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
freezer.move_to(start + timedelta(seconds=8))
|
||||
hass.states.async_set(entity_id, STATE_OFF, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
freezer.move_to(start + timedelta(seconds=10))
|
||||
with patch(
|
||||
"homeassistant.components.recorder.history.get_significant_states",
|
||||
side_effect=history_error,
|
||||
):
|
||||
test = await _setup_attr_state_condition(
|
||||
hass,
|
||||
entity_ids=entity_id,
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
|
||||
# Fell back to the conservative anchor (last_updated bumped at t=8),
|
||||
# so the 5s `for:` is not satisfied 2s later.
|
||||
assert test.async_check() is False
|
||||
|
||||
|
||||
async def test_state_condition_attr_duration_history_lookback_capped(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""The history lookback is capped, regardless of a longer `for:` duration."""
|
||||
entity_id = "test.entity_1"
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start):
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
captured: dict[str, datetime] = {}
|
||||
|
||||
def _capture(hass_: HomeAssistant, start_time: datetime, **kwargs: Any) -> dict:
|
||||
captured["start_time"] = start_time
|
||||
return {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.recorder.history.get_significant_states",
|
||||
side_effect=_capture,
|
||||
):
|
||||
await _setup_attr_state_condition(
|
||||
hass,
|
||||
entity_ids=entity_id,
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"hours": 8}},
|
||||
)
|
||||
|
||||
# The 8h `for:` is clamped to the 6h cap.
|
||||
assert dt_util.utcnow() - captured["start_time"] == MAX_HISTORY_PRIMING_LOOKBACK
|
||||
|
||||
|
||||
async def test_state_condition_attr_duration_history_long_for_uses_live_anchor(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""A `for:` longer than the lookback cap still uses the live anchor.
|
||||
|
||||
The entity has been valid for 10h (longer than the 6h history cap). History
|
||||
alone can only prove the last 6h, but the live state's last_updated (10h
|
||||
ago, never changed) proves the full run, so the 8h `for:` is met. This
|
||||
requires combining history with the live anchor rather than overriding it.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
freezer.move_to(start + timedelta(hours=10))
|
||||
test = await _setup_attr_state_condition(
|
||||
hass,
|
||||
entity_ids=entity_id,
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"hours": 8}},
|
||||
)
|
||||
assert test.async_check() is True
|
||||
|
||||
|
||||
async def test_state_condition_attr_duration_history_loaded_for_added_entity(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""History is loaded for an entity added to the target after setup.
|
||||
|
||||
The entity is only tracked once it gains the targeted label. Resolving its
|
||||
anchor runs in a background task, and the entity is not counted until that
|
||||
completes (no interim conservative anchor). Once loaded, its anchor comes
|
||||
from history just like the initial set: the attribute stayed valid across an
|
||||
unrelated main-state change, so the duration is met even though the live
|
||||
last_updated anchor alone (bumped by the OFF write) would report not met.
|
||||
"""
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Test Late History")
|
||||
entity_reg = er.async_get(hass)
|
||||
entry = entity_reg.async_get_or_create(
|
||||
domain="test", platform="test", unique_id="late_history"
|
||||
)
|
||||
entity_id = entry.entity_id
|
||||
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
# Valid since t=0; unrelated main-state change at t=8 keeps the attr valid.
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
freezer.move_to(start + timedelta(seconds=8))
|
||||
hass.states.async_set(entity_id, STATE_OFF, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# The entity has no label yet, so it is not tracked at setup.
|
||||
freezer.move_to(start + timedelta(seconds=10))
|
||||
test = await _setup_attr_state_condition_with_target(
|
||||
hass,
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
assert test.async_check() is False
|
||||
|
||||
# Adding the label tracks the entity, but its anchor is resolved in a
|
||||
# background task. Until that completes the entity has no _valid_since
|
||||
# entry and is not counted yet — even though it will be met once loaded.
|
||||
# Hold the recorder flush open so the load deterministically cannot
|
||||
# finish before the intermediate check.
|
||||
instance = get_instance(hass)
|
||||
gate: asyncio.Future[None] = hass.loop.create_future()
|
||||
with patch.object(instance, "async_get_commit_future", return_value=gate):
|
||||
entity_reg.async_update_entity(entity_id, labels={label.label_id})
|
||||
assert test.async_check() is False
|
||||
|
||||
# Release the flush; the query runs and the anchor is stored.
|
||||
gate.set_result(None)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# History loaded: continuously valid for 10s → 5s `for:` is met.
|
||||
assert test.async_check() is True
|
||||
|
||||
|
||||
async def test_state_condition_attr_duration_not_counted_while_history_loads(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""The known gap: a new entity is not counted while its history loads.
|
||||
|
||||
Resolving a newly tracked entity's `for:` anchor is asynchronous. Until the
|
||||
recorder read completes the entity has no `_valid_since` entry, so the
|
||||
condition does not count it — even though it will be met once loaded. This
|
||||
holds the recorder read open to observe that window deterministically.
|
||||
"""
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Loading Gap")
|
||||
entity_reg = er.async_get(hass)
|
||||
entry = entity_reg.async_get_or_create(
|
||||
domain="test", platform="test", unique_id="loading_gap"
|
||||
)
|
||||
entity_id = entry.entity_id
|
||||
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# Valid for 10s by the time it is added, so once loaded the 5s `for:`
|
||||
# is met.
|
||||
freezer.move_to(start + timedelta(seconds=10))
|
||||
test = await _setup_attr_state_condition_with_target(
|
||||
hass,
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
assert test.async_check() is False
|
||||
|
||||
# Hold the recorder flush open so the background history load can't
|
||||
# finish, then add the entity to the target.
|
||||
instance = get_instance(hass)
|
||||
gate: asyncio.Future[None] = hass.loop.create_future()
|
||||
with patch.object(instance, "async_get_commit_future", return_value=gate):
|
||||
entity_reg.async_update_entity(entity_id, labels={label.label_id})
|
||||
# Let the prime task start and block on the held flush.
|
||||
await asyncio.sleep(0)
|
||||
# Load in flight → no anchor yet → entity not counted.
|
||||
assert test.async_check() is False
|
||||
|
||||
# Release the flush; the query runs and the anchor is stored.
|
||||
gate.set_result(None)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# Loaded now → met.
|
||||
assert test.async_check() is True
|
||||
|
||||
|
||||
async def test_state_condition_attr_duration_benign_change_during_load_keeps_history(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""A valid live change during the load does not discard history.
|
||||
|
||||
If the entity stays valid while its history loads, the run is unbroken and
|
||||
history's earlier run-start is still accurate, so it is applied. An unrelated
|
||||
attribute write must not reset the anchor to "now".
|
||||
"""
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Benign During Load")
|
||||
entity_reg = er.async_get(hass)
|
||||
entry = entity_reg.async_get_or_create(
|
||||
domain="test", platform="test", unique_id="benign_during_load"
|
||||
)
|
||||
entity_id = entry.entity_id
|
||||
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
# Valid since t=0; history anchors here, well past the 5s `for:`.
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
freezer.move_to(start + timedelta(seconds=10))
|
||||
test = await _setup_attr_state_condition_with_target(
|
||||
hass,
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
|
||||
instance = get_instance(hass)
|
||||
gate: asyncio.Future[None] = hass.loop.create_future()
|
||||
with patch.object(instance, "async_get_commit_future", return_value=gate):
|
||||
entity_reg.async_update_entity(entity_id, labels={label.label_id})
|
||||
await asyncio.sleep(0) # prime task blocks on the held flush
|
||||
|
||||
# Unrelated attribute write while loading: still valid (run unbroken).
|
||||
hass.states.async_set(
|
||||
entity_id, STATE_ON, {"test_attr": True, "other": "x"}
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Advance so the change-time anchor alone would satisfy the 5s `for:`.
|
||||
# The entity must still not be counted while its history is loading —
|
||||
# the live listener leaves primed entities alone, so there is no
|
||||
# interim anchor for the change to set.
|
||||
freezer.move_to(start + timedelta(seconds=18))
|
||||
assert test.async_check() is False
|
||||
|
||||
gate.set_result(None)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# History was applied despite the benign change → valid since t=0 → met.
|
||||
assert test.async_check() is True
|
||||
|
||||
|
||||
async def test_state_condition_attr_duration_invalidation_during_load_discards_history(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""An invalidation during the load discards the (now stale) history.
|
||||
|
||||
The commit flush only guarantees history up to the flush point; a dip that
|
||||
commits after it is invisible to the query, so history would still show the
|
||||
old continuous run. The live listener saw the break, so on revalidation the
|
||||
anchor comes from live tracking (the post-dip time), not history.
|
||||
"""
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Dip During Load")
|
||||
entity_reg = er.async_get(hass)
|
||||
entry = entity_reg.async_get_or_create(
|
||||
domain="test", platform="test", unique_id="dip_during_load"
|
||||
)
|
||||
entity_id = entry.entity_id
|
||||
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
# Valid since t=0; history alone would (stalely) anchor here and report
|
||||
# the 5s `for:` as met.
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
freezer.move_to(start + timedelta(seconds=10))
|
||||
test = await _setup_attr_state_condition_with_target(
|
||||
hass,
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
|
||||
instance = get_instance(hass)
|
||||
gate: asyncio.Future[None] = hass.loop.create_future()
|
||||
with patch.object(instance, "async_get_commit_future", return_value=gate):
|
||||
entity_reg.async_update_entity(entity_id, labels={label.label_id})
|
||||
await asyncio.sleep(0) # prime task blocks on the held flush
|
||||
|
||||
# Dip invalid then valid again while loading: the run broke.
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": False})
|
||||
await asyncio.sleep(0)
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
gate.set_result(None)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# Stale history was discarded; the anchor is the post-dip time (now), so
|
||||
# the 5s `for:` is not yet met.
|
||||
assert test.async_check() is False
|
||||
|
||||
|
||||
async def test_state_condition_attr_duration_history_flushes_before_query(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Pending recorder writes are flushed before the history query.
|
||||
|
||||
`get_significant_states` only sees committed rows. A state change that
|
||||
already happened but is still queued in the recorder would be missed by
|
||||
both the query and the live listener (which only sees changes after it
|
||||
subscribes), so the queue must be flushed before querying.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_ON, {"test_attr": True})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
call_order: list[str] = []
|
||||
instance = get_instance(hass)
|
||||
real_commit_future = instance.async_get_commit_future
|
||||
real_query = history.get_significant_states
|
||||
|
||||
def _spy_commit_future() -> Any:
|
||||
call_order.append("flush")
|
||||
return real_commit_future()
|
||||
|
||||
def _spy_query(*args: Any, **kwargs: Any) -> Any:
|
||||
call_order.append("query")
|
||||
return real_query(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(instance, "async_get_commit_future", _spy_commit_future),
|
||||
patch(
|
||||
"homeassistant.components.recorder.history.get_significant_states",
|
||||
_spy_query,
|
||||
),
|
||||
):
|
||||
await _setup_attr_state_condition(
|
||||
hass,
|
||||
entity_ids=entity_id,
|
||||
states={True},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
|
||||
assert call_order == ["flush", "query"]
|
||||
|
||||
|
||||
async def test_state_condition_multi_state_duration_uses_history(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""A multi-state condition reads history to anchor across in-set toggles.
|
||||
|
||||
A transition within the valid set (here ON->OFF) bumps `last_changed` even
|
||||
though the condition stays valid, so `last_changed` alone is too
|
||||
conservative; history finds the true start of the run.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
start = dt_util.utcnow()
|
||||
with freeze_time(start) as freezer:
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
# Toggle within the valid set: still valid, but last_changed jumps to t=8.
|
||||
freezer.move_to(start + timedelta(seconds=8))
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
freezer.move_to(start + timedelta(seconds=10))
|
||||
test = await _setup_state_condition(
|
||||
hass,
|
||||
states={STATE_ON, STATE_OFF},
|
||||
target_config={CONF_ENTITY_ID: [entity_id]},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
|
||||
# Valid (ON or OFF) for 10s. last_changed alone (t=8) would report not
|
||||
# met; history anchors to the start of the run, so the 5s `for:` is met.
|
||||
assert test.async_check() is True
|
||||
|
||||
|
||||
async def test_state_condition_single_state_duration_skips_history(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""A single-state condition uses last_changed directly and reads no history.
|
||||
|
||||
`_needs_duration_tracking` is False for single-state, no-value_source
|
||||
conditions, so setup never sets up tracking or queries the recorder.
|
||||
"""
|
||||
hass.states.async_set("test.entity_1", STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.recorder.history.get_significant_states",
|
||||
return_value={},
|
||||
) as mock_history:
|
||||
test = await _setup_state_condition(
|
||||
hass,
|
||||
states=STATE_ON,
|
||||
target_config={CONF_ENTITY_ID: ["test.entity_1"]},
|
||||
condition_options={CONF_FOR: {"seconds": 5}},
|
||||
)
|
||||
|
||||
mock_history.assert_not_called()
|
||||
|
||||
# The anchor comes straight from state.last_changed, so the duration is met.
|
||||
freezer.tick(timedelta(seconds=6))
|
||||
assert test.async_check() is True
|
||||
|
||||
|
||||
class _AttributeBackedStateCondition(EntityConditionBase):
|
||||
"""Test condition that reads an attribute directly in `is_valid_state`.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Test service helpers."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.group import Group
|
||||
@@ -544,7 +546,7 @@ async def test_async_track_target_selector_state_change_event_empty_selector(
|
||||
"""Handle state change events."""
|
||||
|
||||
with pytest.raises(HomeAssistantError) as excinfo:
|
||||
target.async_track_target_selector_state_change_event(
|
||||
await target.async_track_target_selector_state_change_event(
|
||||
hass, {}, state_change_callback
|
||||
)
|
||||
assert str(excinfo.value) == (
|
||||
@@ -626,7 +628,7 @@ async def test_async_track_target_selector_state_change_event(
|
||||
ATTR_FLOOR_ID: floor,
|
||||
ATTR_LABEL_ID: label,
|
||||
}
|
||||
unsub = target.async_track_target_selector_state_change_event(
|
||||
unsub = await target.async_track_target_selector_state_change_event(
|
||||
hass, selector_config, state_change_callback
|
||||
)
|
||||
|
||||
@@ -762,7 +764,7 @@ async def test_async_track_target_selector_state_change_event_filter(
|
||||
ATTR_ENTITY_ID: targeted_entity,
|
||||
ATTR_LABEL_ID: label,
|
||||
}
|
||||
unsub = target.async_track_target_selector_state_change_event(
|
||||
unsub = await target.async_track_target_selector_state_change_event(
|
||||
hass, selector_config, state_change_callback, entity_filter
|
||||
)
|
||||
|
||||
@@ -835,7 +837,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
hass.states.async_set(entity_b.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
unsub = target.async_track_target_selector_state_change_event(
|
||||
unsub = await target.async_track_target_selector_state_change_event(
|
||||
hass,
|
||||
{ATTR_LABEL_ID: label.label_id},
|
||||
state_change_callback,
|
||||
@@ -889,6 +891,59 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
assert len(entity_updates) == 0
|
||||
|
||||
|
||||
async def test_async_track_target_selector_cancels_update_task_on_unsubscribe(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Unsubscribing cancels an in-flight registry-driven update task."""
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event() # intentionally never set
|
||||
cancelled = False
|
||||
|
||||
@callback
|
||||
def state_change_callback(event: target.TargetStateChangedData) -> None:
|
||||
"""Handle state change events."""
|
||||
|
||||
async def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
nonlocal cancelled
|
||||
started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
raise
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Cancel Test")
|
||||
entity = entity_reg.async_get_or_create(
|
||||
domain="light", platform="test", unique_id="cancel_a"
|
||||
)
|
||||
hass.states.async_set(entity.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# No entity has the label yet, so the awaited initial update does not fire.
|
||||
unsub = await target.async_track_target_selector_state_change_event(
|
||||
hass,
|
||||
{ATTR_LABEL_ID: label.label_id},
|
||||
state_change_callback,
|
||||
on_entities_update=on_entities_update,
|
||||
)
|
||||
|
||||
# Registry change starts the update task, which blocks indefinitely.
|
||||
entity_reg.async_update_entity(entity.entity_id, labels={label.label_id})
|
||||
await started.wait()
|
||||
|
||||
# Unsubscribing cancels the in-flight task.
|
||||
unsub()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert cancelled is True
|
||||
|
||||
# Drain (a no-op once cancelled; releases the task if cancellation regressed).
|
||||
release.set()
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
|
||||
async def test_async_track_target_selector_no_on_entities_update(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
@@ -904,7 +959,7 @@ async def test_async_track_target_selector_no_on_entities_update(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# No on_entities_update — should work without errors
|
||||
unsub = target.async_track_target_selector_state_change_event(
|
||||
unsub = await target.async_track_target_selector_state_change_event(
|
||||
hass,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
state_change_callback,
|
||||
|
||||
@@ -25,6 +25,7 @@ def test_render_all_conclusive_collapses_details() -> None:
|
||||
CheckKind.REPO_PUBLIC: _pass("public"),
|
||||
CheckKind.CI_UPLOAD: _pass("attestation found"),
|
||||
CheckKind.RELEASE_PIPELINE: _pass("OIDC via attestation"),
|
||||
CheckKind.SECURITY: _pass("baseline scan clean"),
|
||||
CheckKind.PR_LINK: _pass("link found"),
|
||||
CheckKind.ASYNC_BLOCKING: _pass("no blocking calls in async"),
|
||||
},
|
||||
@@ -49,6 +50,7 @@ def test_render_needs_agent_emits_generic_placeholders() -> None:
|
||||
CheckKind.REPO_PUBLIC: CheckResult(CheckStatus.NEEDS_AGENT, ""),
|
||||
CheckKind.CI_UPLOAD: CheckResult(CheckStatus.WARN, "no attestation"),
|
||||
CheckKind.RELEASE_PIPELINE: CheckResult(CheckStatus.NEEDS_AGENT, ""),
|
||||
CheckKind.SECURITY: CheckResult(CheckStatus.NEEDS_AGENT, ""),
|
||||
CheckKind.PR_LINK: CheckResult(CheckStatus.NEEDS_AGENT, ""),
|
||||
CheckKind.ASYNC_BLOCKING: CheckResult(CheckStatus.NEEDS_AGENT, ""),
|
||||
},
|
||||
@@ -58,6 +60,8 @@ def test_render_needs_agent_emits_generic_placeholders() -> None:
|
||||
assert "{{CHECK_DETAIL:pkg:repo_public}}" in rendered
|
||||
assert "{{CHECK_CELL:pkg:release_pipeline}}" in rendered
|
||||
assert "{{CHECK_DETAIL:pkg:release_pipeline}}" in rendered
|
||||
assert "{{CHECK_CELL:pkg:security}}" in rendered
|
||||
assert "{{CHECK_DETAIL:pkg:security}}" 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
|
||||
|
||||
@@ -60,6 +60,7 @@ def test_runner_attestation_recognised(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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 pkg.checks[CheckKind.SECURITY].status == CheckStatus.NEEDS_AGENT
|
||||
assert result.needs_agent is True
|
||||
|
||||
|
||||
@@ -159,10 +160,11 @@ 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, pr_link and async_blocking short-circuit to FAIL
|
||||
# No repo URL → 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 pkg.checks[CheckKind.SECURITY].status == CheckStatus.FAIL
|
||||
assert result.needs_agent is False
|
||||
|
||||
|
||||
@@ -198,7 +200,9 @@ def test_runner_pypi_found_but_no_repo_url_fails_repo_checks(
|
||||
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 pkg.checks[CheckKind.SECURITY].status == CheckStatus.FAIL
|
||||
assert "does not advertise" in pkg.checks[CheckKind.REPO_PUBLIC].details
|
||||
assert "cannot be inspected" in pkg.checks[CheckKind.SECURITY].details
|
||||
|
||||
|
||||
def test_runner_async_blocking_new_package_full_review(
|
||||
|
||||
+16
-67
@@ -1336,14 +1336,22 @@ async def test_eventbus_listen_once_run_immediately_coro(hass: HomeAssistant) ->
|
||||
async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None:
|
||||
"""Test dispatch order when a listener fires an event synchronously.
|
||||
|
||||
Event dispatch is reentrant: an event fired from within a synchronous
|
||||
listener is dispatched immediately, nested inside the dispatch of the
|
||||
outer event.
|
||||
|
||||
The implementation of event listeners is such that listeners are called
|
||||
in the order they were registered
|
||||
|
||||
Event dispatch is however non-reentrant: an event fired from within a
|
||||
synchronous listener is queued and dispatched after the dispatch of the
|
||||
outer event completes. All listeners therefore observe events in fire
|
||||
order, regardless of their registration position relative to the
|
||||
listener which fires the nested event.
|
||||
As a result, the order in which a listener observes the two
|
||||
events depends on its registration position relative to the listener
|
||||
which fires the nested event: listeners registered before it observe
|
||||
fire order, listeners registered after it observe the nested event
|
||||
first.
|
||||
|
||||
This test documents the current behavior rather than guarantees it: a
|
||||
non-reentrant (queued) dispatch would make all listeners observe fire
|
||||
order.
|
||||
"""
|
||||
observed_before: list[str] = []
|
||||
observed_after: list[str] = []
|
||||
@@ -1370,74 +1378,15 @@ async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None:
|
||||
|
||||
hass.bus.async_fire("test_outer")
|
||||
|
||||
# All listeners observe fire order, regardless of registration position
|
||||
# relative to the nesting listener.
|
||||
# Registered before the nesting listener: observes fire order.
|
||||
assert observed_before == ["test_outer", "test_nested"]
|
||||
assert observed_after == ["test_outer", "test_nested"]
|
||||
# Registered after the nesting listener: observes inverted order.
|
||||
assert observed_after == ["test_nested", "test_outer"]
|
||||
|
||||
for unsub in unsubs:
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_eventbus_nested_fire_endless_loop_guard(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test that event listeners firing events in an endless loop are stopped.
|
||||
|
||||
A listener which unconditionally fires an event it also listens to would
|
||||
keep the dispatch drain loop running forever. Once the per-dispatch queue
|
||||
limit is reached, the bus stops queuing further events and raises in the
|
||||
firing listener; the raise is caught and logged by the per-listener error
|
||||
handling.
|
||||
"""
|
||||
calls: list[ha.Event] = []
|
||||
|
||||
@ha.callback
|
||||
def refire(event: ha.Event) -> None:
|
||||
calls.append(event)
|
||||
hass.bus.async_fire("test_loop")
|
||||
|
||||
unsub = hass.bus.async_listen("test_loop", refire)
|
||||
|
||||
with patch.object(ha, "_MAX_QUEUED_EVENT_DISPATCHES", 10):
|
||||
hass.bus.async_fire("test_loop")
|
||||
|
||||
# The top-level dispatch plus 10 queued dispatches; the next fire is
|
||||
# rejected once the queue limit is reached. The firing listener raises,
|
||||
# which the per-listener error handling catches and logs.
|
||||
assert len(calls) == 11
|
||||
assert "Error running job" in caplog.text
|
||||
assert "are likely firing events in an endless loop" in caplog.text
|
||||
|
||||
unsub()
|
||||
|
||||
# The bus remains functional after the aborted dispatch
|
||||
events = async_capture_events(hass, "test_after")
|
||||
hass.bus.async_fire("test_after")
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
async def test_eventbus_fire_raises_when_queue_limit_reached(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test a nested fire raises once the per-dispatch queue limit is reached.
|
||||
|
||||
A fire issued while an event is being dispatched is queued, but once the
|
||||
limit is reached it is rejected with an error instead of being queued.
|
||||
"""
|
||||
# Simulate being in the middle of dispatching with the queue limit reached
|
||||
hass.bus._dispatching = True
|
||||
hass.bus._queued_event_count = ha._MAX_QUEUED_EVENT_DISPATCHES
|
||||
try:
|
||||
with pytest.raises(HomeAssistantError, match="endless loop"):
|
||||
hass.bus.async_fire("test")
|
||||
# The rejected event is not queued
|
||||
assert len(hass.bus._event_queue) == 0
|
||||
finally:
|
||||
hass.bus._dispatching = False
|
||||
hass.bus._queued_event_count = 0
|
||||
|
||||
|
||||
async def test_eventbus_unsubscribe_listener(hass: HomeAssistant) -> None:
|
||||
"""Test unsubscribe listener from returned function."""
|
||||
calls = []
|
||||
|
||||
Reference in New Issue
Block a user