mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 19:51:39 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a1baa9573 |
+19
-17
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -59,13 +59,15 @@ permissions: {}
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs: pre_activation
|
||||
needs:
|
||||
- extract_pr_number
|
||||
- pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
@@ -189,20 +191,20 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -231,12 +233,12 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -321,6 +323,7 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: "gh-aw-copilot-${{ github.workflow }}"
|
||||
@@ -450,9 +453,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_eaae5443153d0b45_EOF'
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -644,7 +647,7 @@ jobs:
|
||||
|
||||
mkdir -p /home/runner/.copilot
|
||||
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
|
||||
cat << GH_AW_MCP_CONFIG_d99df59573a98681_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -654,7 +657,7 @@ jobs:
|
||||
"GITHUB_HOST": "\${GITHUB_SERVER_URL}",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
|
||||
"GITHUB_READ_ONLY": "1",
|
||||
"GITHUB_TOOLSETS": "repos,pull_requests"
|
||||
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
|
||||
},
|
||||
"guard-policies": {
|
||||
"allow-only": {
|
||||
@@ -688,7 +691,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_d99df59573a98681_EOF
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -1281,7 +1284,6 @@ jobs:
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
network:
|
||||
allowed:
|
||||
@@ -13,7 +14,7 @@ network:
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [repos, pull_requests]
|
||||
toolsets: [default, actions]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
@@ -43,7 +44,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.id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
@@ -82,289 +83,296 @@ description: >
|
||||
|
||||
# Check requirements (AW)
|
||||
|
||||
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.
|
||||
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.**
|
||||
|
||||
## Step 1 — Read the artifact
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
|
||||
Read the JSON directly for the full schema. Key fields:
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
|
||||
- `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).
|
||||
The JSON has this shape:
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
|
||||
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 `package` in `packages`:
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
For each `(check_kind, result)` in `package.checks` where
|
||||
`result.status == "needs_agent"`:
|
||||
|
||||
❌ Internal error: deterministic artifact contains an unknown check kind
|
||||
(`<check_kind>` on `<pkg>`).
|
||||
```
|
||||
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:
|
||||
|
||||
Then stop. Do not improvise a verdict.
|
||||
```
|
||||
<!-- 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.
|
||||
|
||||
## Step 3 — Post the comment
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
## Check instructions
|
||||
|
||||
### Check kind: `repo_public`
|
||||
|
||||
`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.
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
|
||||
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.`.
|
||||
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.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
Fetch the PR body via the `pull_requests` MCP using `pr_number`. Extract URLs.
|
||||
Verify the PR description contains the right link for the change.
|
||||
|
||||
- **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>.`
|
||||
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>.`
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
|
||||
Inspect the upstream's publish-to-PyPI CI. Host-specific lookup, same
|
||||
rubric:
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
|
||||
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.
|
||||
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.`
|
||||
|
||||
### Check kind: `async_blocking`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
|
||||
**Step 1 — async surface?**
|
||||
- `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.
|
||||
|
||||
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.
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
|
||||
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.)
|
||||
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}`).
|
||||
|
||||
**Step 2 — review the surface**
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
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.*
|
||||
|
||||
**Blocking patterns to flag inside `async def`:**
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
|
||||
- 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`,
|
||||
Inspect **every `async def` in the public modules** for blocking
|
||||
patterns. Walk transitively into helpers the async functions call.
|
||||
|
||||
#### Step 2b — Mode: version bump (`old_version` is a string)
|
||||
|
||||
Fetch the diff between the two tags and review **only changed lines**:
|
||||
|
||||
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
|
||||
the `github` MCP tool, or
|
||||
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
|
||||
via `web-fetch`. Try the common tag formats in order until one
|
||||
resolves: `v{version}`, `{version}`, `release-{version}`.
|
||||
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
|
||||
- Other hosts: use the project's equivalent compare URL via
|
||||
`web-fetch`.
|
||||
|
||||
If neither tag format resolves on the host, fall back to a full review
|
||||
(Step 2a) and mention in the detail that the diff was unavailable.
|
||||
|
||||
When reviewing the diff, only flag blocking patterns that appear in
|
||||
**added lines** *inside or reachable from* an `async def`. A blocking
|
||||
call that existed in `old_version` and is unchanged is not a regression
|
||||
for this bump.
|
||||
|
||||
#### Step 3 — Blocking patterns to look for
|
||||
|
||||
In both modes, the patterns to flag inside `async def` bodies are:
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
|
||||
`AsyncClient`), `pycurl`.
|
||||
- `time.sleep(` (must be `await asyncio.sleep(`).
|
||||
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
|
||||
blocking `select.select`.
|
||||
- File I/O 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`.
|
||||
- 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_*`).
|
||||
|
||||
Calls dispatched to an executor (`run_in_executor`,
|
||||
`asyncio.to_thread`, `anyio.to_thread.run_sync`) do **not** count as
|
||||
blocking.
|
||||
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.
|
||||
|
||||
**Verdict:**
|
||||
#### Step 4 — Verdict
|
||||
|
||||
- ✅ — 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.
|
||||
- ✅ — no offending blocking pattern in the surface being reviewed
|
||||
(whole tree for a new package, added lines for a bump). For a bump,
|
||||
phrase the detail as `No new blocking calls introduced in
|
||||
{old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking calls exist only in sync helpers that the async API
|
||||
does not call, or only on a clearly non-hot path (e.g. one-shot
|
||||
setup before the event loop is running). Cite at least one
|
||||
`<file>:<line>` and explain why it is not on the hot path.
|
||||
- ❌ — a blocking call is reachable from an `async def` that is part
|
||||
of the public API on the request / polling path (for a bump: the
|
||||
call was introduced or moved onto the hot path by this version).
|
||||
Cite the offending `<file>:<line>` as a clickable link on the repo
|
||||
host so the contributor can jump to it.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive; 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.
|
||||
- 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.
|
||||
|
||||
@@ -31,7 +31,7 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -144,9 +144,7 @@ def _sensor_device_info_to_hass(
|
||||
adv: Aranet4Advertisement,
|
||||
) -> DeviceInfo:
|
||||
"""Convert a sensor device info to hass device info."""
|
||||
hass_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, adv.device.address)}
|
||||
)
|
||||
hass_device_info = DeviceInfo({})
|
||||
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==6.2.0.44.0"],
|
||||
"requirements": ["mozart-api==5.3.1.108.2"],
|
||||
"zeroconf": ["_bangolufsen._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ BINARY_SENSOR_TYPES = (
|
||||
key="open",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="input",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.6"]
|
||||
"requirements": ["home-assistant-frontend==20260527.5"]
|
||||
}
|
||||
|
||||
@@ -40,8 +40,6 @@ 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,
|
||||
@@ -71,35 +69,6 @@ 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,
|
||||
@@ -115,17 +84,30 @@ def setup_platform(
|
||||
_LOGGER.error("Unable to find iTach")
|
||||
return
|
||||
|
||||
devices = [
|
||||
_setup_remote_entity(itachip2ir, device_config)
|
||||
for device_config in config[CONF_DEVICES]
|
||||
]
|
||||
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))
|
||||
add_entities(devices, True)
|
||||
|
||||
|
||||
class ITachIP2IRRemote(remote.RemoteEntity):
|
||||
"""Device that sends commands to an ITachIP2IR device."""
|
||||
|
||||
def __init__(self, itachip2ir: Any, name: str | None, ir_count: int) -> None:
|
||||
def __init__(self, itachip2ir, name, ir_count):
|
||||
"""Initialize device."""
|
||||
self.itachip2ir = itachip2ir
|
||||
self._attr_is_on = False
|
||||
|
||||
@@ -422,7 +422,9 @@ 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.BINARY_SENSOR, Platform.CLIMATE]
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"""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,6 +103,7 @@ 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
|
||||
@@ -185,12 +186,12 @@ class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current room temperature."""
|
||||
return self.unit.room_temperature
|
||||
return self.unit.room_temperature if self.unit else None
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self.unit.set_temperature
|
||||
return self.unit.set_temperature if self.unit else None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
|
||||
@@ -26,9 +26,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username")
|
||||
),
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ 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):
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"forced_hot_water": {
|
||||
"default": "mdi:water-boiler"
|
||||
},
|
||||
"standby": {
|
||||
"default": "mdi:power-sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,17 +24,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"error": {
|
||||
"name": "Error"
|
||||
},
|
||||
"forced_hot_water": {
|
||||
"name": "Forced hot water"
|
||||
},
|
||||
"standby": {
|
||||
"name": "Standby"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"ata_unit": {
|
||||
"state_attributes": {
|
||||
|
||||
+67
-2
@@ -5,7 +5,7 @@ of entities and react to changes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import UserDict, defaultdict
|
||||
from collections import UserDict, defaultdict, deque
|
||||
from collections.abc import (
|
||||
Callable,
|
||||
Collection,
|
||||
@@ -1428,10 +1428,23 @@ 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 fired by event listeners which will be dispatched
|
||||
# from a single top-level fire, 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", "_hass", "_listeners", "_match_all_listeners")
|
||||
__slots__ = (
|
||||
"_debug",
|
||||
"_dispatching",
|
||||
"_fire_queue",
|
||||
"_hass",
|
||||
"_listeners",
|
||||
"_match_all_listeners",
|
||||
)
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize a new event bus."""
|
||||
@@ -1441,6 +1454,10 @@ class EventBus:
|
||||
self._match_all_listeners: list[_FilterableJobType[Any]] = []
|
||||
self._listeners[MATCH_ALL] = self._match_all_listeners
|
||||
self._hass = hass
|
||||
self._fire_queue: deque[
|
||||
tuple[EventType[Any] | str, Any, EventOrigin, Context | None, float]
|
||||
] = deque()
|
||||
self._dispatching = False
|
||||
self._async_logging_changed()
|
||||
self.async_listen(EVENT_LOGGING_CHANGED, self._async_logging_changed)
|
||||
|
||||
@@ -1520,6 +1537,54 @@ class EventBus:
|
||||
"Bus:Handling %s", _event_repr(event_type, origin, event_data)
|
||||
)
|
||||
|
||||
if self._dispatching:
|
||||
# Non-reentrant dispatch: an event fired from within an event
|
||||
# listener is queued and dispatched after the current dispatch
|
||||
# completes, so all listeners observe events in fire order. The
|
||||
# fire time is captured now since dispatch is deferred.
|
||||
self._fire_queue.append(
|
||||
(event_type, event_data, origin, context, time_fired or time.time())
|
||||
)
|
||||
return
|
||||
|
||||
self._dispatching = True
|
||||
try:
|
||||
self._async_dispatch(event_type, event_data, origin, context, time_fired)
|
||||
if self._fire_queue:
|
||||
self._async_drain_fire_queue()
|
||||
finally:
|
||||
self._dispatching = False
|
||||
|
||||
@callback
|
||||
def _async_drain_fire_queue(self) -> None:
|
||||
"""Dispatch events queued by event listeners, in fire order."""
|
||||
fire_queue = self._fire_queue
|
||||
dispatched = 0
|
||||
while fire_queue:
|
||||
if dispatched >= _MAX_QUEUED_EVENT_DISPATCHES:
|
||||
_LOGGER.error(
|
||||
"Aborting event dispatch: %d events were fired by event"
|
||||
" listeners while dispatching a single event; event"
|
||||
" listeners are likely firing events in an endless loop."
|
||||
" Dropping queued events: %s",
|
||||
dispatched,
|
||||
", ".join(sorted({str(item[0]) for item in fire_queue})),
|
||||
)
|
||||
fire_queue.clear()
|
||||
return
|
||||
self._async_dispatch(*fire_queue.popleft())
|
||||
dispatched += 1
|
||||
|
||||
@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
|
||||
|
||||
@@ -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.6
|
||||
home-assistant-frontend==20260527.5
|
||||
home-assistant-intents==2026.6.1
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
|
||||
|
||||
from typing import Final
|
||||
|
||||
FRONTEND_VERSION: Final[str] = "20260527.6"
|
||||
FRONTEND_VERSION: Final[str] = "20260527.5"
|
||||
|
||||
MDI_ICONS: Final[set[str]] = {
|
||||
"ab-testing",
|
||||
|
||||
Generated
+2
-2
@@ -1272,7 +1272,7 @@ hole==0.9.0
|
||||
holidays==0.98
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260527.6
|
||||
home-assistant-frontend==20260527.5
|
||||
|
||||
# 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==6.2.0.44.0
|
||||
mozart-api==5.3.1.108.2
|
||||
|
||||
# homeassistant.components.mullvad
|
||||
mullvad-api==1.0.0
|
||||
|
||||
@@ -25,7 +25,6 @@ class CheckKind(StrEnum):
|
||||
REPO_PUBLIC = "repo_public"
|
||||
CI_UPLOAD = "ci_upload"
|
||||
RELEASE_PIPELINE = "release_pipeline"
|
||||
SECURITY = "security"
|
||||
PR_LINK = "pr_link"
|
||||
ASYNC_BLOCKING = "async_blocking"
|
||||
YANKED = "yanked"
|
||||
|
||||
@@ -21,7 +21,6 @@ _CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
|
||||
(CheckKind.REPO_PUBLIC, "Repo Public"),
|
||||
(CheckKind.CI_UPLOAD, "CI Upload"),
|
||||
(CheckKind.RELEASE_PIPELINE, "Release Pipeline"),
|
||||
(CheckKind.SECURITY, "Security"),
|
||||
(CheckKind.PR_LINK, "PR Link"),
|
||||
(CheckKind.ASYNC_BLOCKING, "Async Safe"),
|
||||
)
|
||||
|
||||
@@ -16,8 +16,6 @@ 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
|
||||
@@ -139,7 +137,6 @@ 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,
|
||||
@@ -149,10 +146,6 @@ 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 "
|
||||
@@ -175,10 +168,6 @@ def run_checks(
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = fail
|
||||
pkg.checks[CheckKind.PR_LINK] = fail
|
||||
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
|
||||
pkg.checks[CheckKind.SECURITY] = CheckResult(
|
||||
CheckStatus.FAIL,
|
||||
"No source repository URL on PyPI — source cannot be inspected.",
|
||||
)
|
||||
result = CheckRunResult(pr_number=pr_number, packages=packages)
|
||||
result.rendered_comment = render_comment(result)
|
||||
return result
|
||||
|
||||
@@ -85,7 +85,6 @@ 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()
|
||||
@@ -147,7 +146,6 @@ 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()
|
||||
@@ -229,7 +227,6 @@ 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()
|
||||
@@ -313,7 +310,6 @@ async def test_sensors_aranetrn(
|
||||
assert device.model == "Aranet Radon"
|
||||
assert device.sw_version == "v1.6.4"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from uuid import UUID
|
||||
|
||||
from mozart_api.models import (
|
||||
Action,
|
||||
@@ -255,8 +254,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=UUID("0b4552f8-7ac6-5046-9d44-5410a815b8d6"),
|
||||
parent_menu_item_id=UUID("eee0c2d0-2b3a-4899-a708-658475c38926"),
|
||||
next_sibling_menu_item_id="0b4552f8-7ac6-5046-9d44-5410a815b8d6",
|
||||
parent_menu_item_id="eee0c2d0-2b3a-4899-a708-658475c38926",
|
||||
available=None,
|
||||
content=ContentItem(
|
||||
categories=["music"],
|
||||
@@ -265,7 +264,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
source=SourceTypeEnum(value="netRadio"),
|
||||
),
|
||||
fixed=True,
|
||||
id=UUID("b355888b-2cde-5f94-8592-d47b71d52a27"),
|
||||
id="b355888b-2cde-5f94-8592-d47b71d52a27",
|
||||
),
|
||||
# Has "hdmi" as category, so should be included in video sources
|
||||
"b6591565-80f4-4356-bcd9-c92ca247f0a9": RemoteMenuItem(
|
||||
@@ -294,8 +293,8 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
dynamic_list="none",
|
||||
first_child_menu_item_id=None,
|
||||
label="HDMI A",
|
||||
next_sibling_menu_item_id=UUID("0ba98974-7b1f-40dc-bc48-fbacbb0f1793"),
|
||||
parent_menu_item_id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"),
|
||||
next_sibling_menu_item_id="0ba98974-7b1f-40dc-bc48-fbacbb0f1793",
|
||||
parent_menu_item_id="b66c835b-6b98-4400-8f84-6348043792c7",
|
||||
available=True,
|
||||
content=ContentItem(
|
||||
categories=["hdmi"],
|
||||
@@ -304,7 +303,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
source=SourceTypeEnum(value="tv"),
|
||||
),
|
||||
fixed=False,
|
||||
id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"),
|
||||
id="b6591565-80f4-4356-bcd9-c92ca247f0a9",
|
||||
),
|
||||
# The parent remote menu item. Has the TV label and
|
||||
# should therefore not be included in video sources
|
||||
@@ -313,14 +312,14 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
scene_list=None,
|
||||
disabled=False,
|
||||
dynamic_list="none",
|
||||
first_child_menu_item_id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"),
|
||||
first_child_menu_item_id="b6591565-80f4-4356-bcd9-c92ca247f0a9",
|
||||
label="TV",
|
||||
next_sibling_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"),
|
||||
next_sibling_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb",
|
||||
parent_menu_item_id=None,
|
||||
available=True,
|
||||
content=None,
|
||||
fixed=True,
|
||||
id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"),
|
||||
id="b66c835b-6b98-4400-8f84-6348043792c7",
|
||||
),
|
||||
# Has an empty content, so should not be included
|
||||
"64c9da45-3682-44a4-8030-09ed3ef44160": RemoteMenuItem(
|
||||
@@ -331,11 +330,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=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"),
|
||||
parent_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb",
|
||||
available=True,
|
||||
content=None,
|
||||
fixed=True,
|
||||
id=UUID("64c9da45-3682-44a4-8030-09ed3ef44160"),
|
||||
id="64c9da45-3682-44a4-8030-09ed3ef44160",
|
||||
),
|
||||
}
|
||||
client.get_beolink_peers = AsyncMock()
|
||||
@@ -344,13 +343,11 @@ 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()
|
||||
@@ -359,13 +356,11 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
friendly_name=TEST_FRIENDLY_NAME_3,
|
||||
jid=TEST_JID_3,
|
||||
ip_address=TEST_HOST_3,
|
||||
audio_transport="v2",
|
||||
),
|
||||
BeolinkPeer(
|
||||
friendly_name=TEST_FRIENDLY_NAME_4,
|
||||
jid=TEST_JID_4,
|
||||
ip_address=TEST_HOST_4,
|
||||
audio_transport="v2",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ TEST_PLAYBACK_METADATA_VIDEO = PlaybackContentMetadata(
|
||||
title="HDMI A",
|
||||
source_internal_id="hdmi_1",
|
||||
output_channel_processing="TrueImage",
|
||||
output_channels="5.0.2",
|
||||
output_Channels="5.0.2",
|
||||
)
|
||||
TEST_PLAYBACK_ERROR = PlaybackError(error="Test error")
|
||||
TEST_PLAYBACK_PROGRESS = PlaybackProgress(progress=123)
|
||||
|
||||
@@ -582,7 +582,7 @@ async def test_async_update_beolink_listener(
|
||||
playback_metadata_callback(
|
||||
PlaybackContentMetadata(
|
||||
remote_leader=BeolinkLeader(
|
||||
friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2, audio_transport="v2"
|
||||
friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -45,75 +45,23 @@ 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(
|
||||
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,
|
||||
rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Test binary_sensor initialisation."""
|
||||
_, entity_id = request.getfixturevalue(fixture_name)
|
||||
_, entity_id = rainsensor
|
||||
entry = await async_setup_entity(hass, entity_id)
|
||||
assert entry.unique_id == unique_id
|
||||
assert entry.unique_id == "BleBox-windRainSensor-ea68e74f4f49-0.rain"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == expected_name
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == expected_device_class
|
||||
assert state.state == expected_state
|
||||
assert state.name == "My rain sensor windRainSensor-0.rain"
|
||||
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE
|
||||
assert state.state == STATE_ON
|
||||
|
||||
device = device_registry.async_get(entry.device_id)
|
||||
assert device.name == expected_device_name
|
||||
|
||||
assert device.name == "My rain sensor"
|
||||
|
||||
|
||||
async def test_open_sensor_init(
|
||||
|
||||
@@ -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) as err:
|
||||
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
|
||||
await hass.services.async_call(
|
||||
alarm_control_panel.DOMAIN,
|
||||
service,
|
||||
@@ -250,7 +250,6 @@ 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
|
||||
|
||||
@@ -1109,9 +1108,8 @@ 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) as err:
|
||||
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
|
||||
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
|
||||
|
||||
@@ -1228,9 +1226,8 @@ 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) as err:
|
||||
with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"):
|
||||
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
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
# 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',
|
||||
})
|
||||
# ---
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Tests for the MELCloud Home binary sensor platform."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
ATA_ERROR_ENTITY_ID = "binary_sensor.living_room_ac_error"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
|
||||
"""Make sure all entities are enabled."""
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_melcloud_client")
|
||||
async def test_all_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test all entities."""
|
||||
with patch(
|
||||
"homeassistant.components.melcloud_home.PLATFORMS",
|
||||
[Platform.BINARY_SENSOR],
|
||||
):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, mock_config_entry.entry_id
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test the MELCloud Home climate platform."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiomelcloudhome import (
|
||||
ATAFanSpeed,
|
||||
@@ -29,7 +29,6 @@ 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
|
||||
@@ -51,14 +50,8 @@ async def test_climate_platform(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test all climate entity states and attributes from fixture data."""
|
||||
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
|
||||
)
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -25,7 +25,6 @@ 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"),
|
||||
},
|
||||
@@ -50,7 +49,6 @@ 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, ""),
|
||||
},
|
||||
@@ -60,8 +58,6 @@ 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,7 +60,6 @@ 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
|
||||
|
||||
|
||||
@@ -160,11 +159,10 @@ def test_runner_marks_missing_version_as_fail(
|
||||
pkg = result.packages[0]
|
||||
assert pkg.checks[CheckKind.CI_UPLOAD].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.RELEASE_PIPELINE].status == CheckStatus.FAIL
|
||||
# No repo URL → short-circuit to FAIL
|
||||
# No repo URL → repo_public, pr_link and async_blocking short-circuit to FAIL
|
||||
assert pkg.checks[CheckKind.REPO_PUBLIC].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.PR_LINK].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.ASYNC_BLOCKING].status == CheckStatus.FAIL
|
||||
assert pkg.checks[CheckKind.SECURITY].status == CheckStatus.FAIL
|
||||
assert result.needs_agent is False
|
||||
|
||||
|
||||
@@ -200,9 +198,7 @@ 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(
|
||||
|
||||
+42
-16
@@ -1336,22 +1336,14 @@ 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
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
observed_before: list[str] = []
|
||||
observed_after: list[str] = []
|
||||
@@ -1378,15 +1370,49 @@ async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None:
|
||||
|
||||
hass.bus.async_fire("test_outer")
|
||||
|
||||
# Registered before the nesting listener: observes fire order.
|
||||
# All listeners observe fire order, regardless of registration position
|
||||
# relative to the nesting listener.
|
||||
assert observed_before == ["test_outer", "test_nested"]
|
||||
# Registered after the nesting listener: observes inverted order.
|
||||
assert observed_after == ["test_nested", "test_outer"]
|
||||
assert observed_after == ["test_outer", "test_nested"]
|
||||
|
||||
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.
|
||||
|
||||
Without the guard, a listener which unconditionally fires an event it
|
||||
also listens to would keep the dispatch drain loop running forever.
|
||||
"""
|
||||
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, then the loop is
|
||||
# aborted and the queued event dropped.
|
||||
assert len(calls) == 11
|
||||
assert "listeners are likely firing events in an endless loop" in caplog.text
|
||||
assert "test_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_unsubscribe_listener(hass: HomeAssistant) -> None:
|
||||
"""Test unsubscribe listener from returned function."""
|
||||
calls = []
|
||||
|
||||
Reference in New Issue
Block a user