Compare commits

..

19 Commits

Author SHA1 Message Date
Erik Montnemery 96a03155c9 Merge branch 'dev' into prime_condition_durations 2026-06-11 21:51:43 +02:00
Mark Purcell 437d33d791 Add diagnostics platform to Daikin integration (#173481) 2026-06-11 21:46:10 +02:00
Erwin Douna 770488f0d4 MELCloud Home fixing typo (#173530) 2026-06-11 21:13:57 +02:00
Erik Montnemery cefbb109d2 Add missing file cleanup to homekit tests (#173513) 2026-06-11 21:03:28 +02:00
Ernst Klamer d9aa99e338 Bump bthome-ble to 3.23.4 (#173526) 2026-06-11 20:55:37 +03:00
Erwin Douna df49891f40 MELCloud Home add binary sensor (#173497) 2026-06-11 18:06:24 +02:00
Bram Kragten 9c86fe2ac5 Update frontend to 20260527.6 (#173522) 2026-06-11 18:00:50 +02:00
Robert Resch 29badf6651 Add basic security check to dependency workflow (#171191)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-06-11 17:38:42 +02:00
jasonjhofmann bd58c08eea Add Bluetooth connection to Aranet devices (#173066)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-11 17:03:14 +02:00
orandasoft b69c13477a Refactor iTach YAML remote platform without behavior changes (#173485) 2026-06-11 16:41:13 +02:00
Robert Resch ea5e8e7982 Rephrase aw check requirements (#171676)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-11 16:31:35 +02:00
Duco Sebel dfa40f807e Remove positional message strings when translation_key is set in manual (#173393) 2026-06-11 16:15:26 +02:00
bkobus-bbx fdb15ce2d7 Add support for inputSensor Blebox devices (#169841) 2026-06-11 16:06:07 +02:00
Erwin Douna ee30f6c085 MELCloud Home follow-up PR to refactor small parts (#173515) 2026-06-11 15:52:06 +02:00
Markus Jacobsen d7af8ed2b3 Bump mozart_api to 6.2.0.44.0 (#173514) 2026-06-11 15:50:54 +02:00
Erik 1631fe58ec Address review comments 2026-06-11 13:48:16 +02:00
Erik 6c1540130c Disambiguate prime vs priming 2026-06-11 09:48:33 +02:00
Erik 2c801453ab Fix flaky test 2026-06-11 08:14:12 +02:00
Erik 1ea8c5d037 Prime condition durations from history 2026-06-10 10:06:33 +02:00
50 changed files with 1928 additions and 522 deletions
+17 -19
View File
@@ -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:
+243 -251
View File
@@ -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.
+4 -2
View File
@@ -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"]
}
+1 -1
View File
@@ -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"]
}
+36 -18
View File
@@ -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": {
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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
+162 -11
View File
@@ -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."""
+66 -15
View File
@@ -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()
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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
+1
View File
@@ -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"
+1
View File
@@ -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"),
)
+11
View File
@@ -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
+4
View File
@@ -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()
+16 -11
View File
@@ -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",
),
]
+1 -1
View File
@@ -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"
)
)
)
+61 -9
View File
@@ -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
+2 -6
View File
@@ -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")
+1
View File
@@ -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
)
+10 -3
View File
@@ -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(
+2 -6
View File
@@ -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(
+2 -6
View File
@@ -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(
+617 -4
View File
@@ -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`.
+60 -5
View File
@@ -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
View File
@@ -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 = []