mirror of
https://github.com/home-assistant/core.git
synced 2026-05-21 16:25:18 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfa5ce9d91 |
@@ -19,6 +19,7 @@ machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
requirements_all.txt linguist-generated=true
|
||||
requirements_test_all.txt linguist-generated=true
|
||||
requirements_test_pre_commit.txt linguist-generated=true
|
||||
script/hassfest/docker/Dockerfile linguist-generated=true
|
||||
.github/workflows/*.lock.yml linguist-generated=true
|
||||
|
||||
@@ -128,7 +128,6 @@
|
||||
"standard-aifc",
|
||||
"standard-telnetlib",
|
||||
"ulid-transform",
|
||||
"unidiff",
|
||||
"url-normalize",
|
||||
"xmltodict"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Check requirements (changes detection)
|
||||
|
||||
# Stage 1 of the agentic Check requirements workflow.
|
||||
# Just kicks off Stage 2 (`check-requirements-dispatcher.yml`) which starts the agentic workflow
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "requirements*.txt"
|
||||
- "homeassistant/package_constraints.txt"
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Requirements files changed
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
steps:
|
||||
- name: Record PR number
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |-
|
||||
echo "Requirements files changed in PR #${PR_NUMBER}"
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Check requirements (deterministic)
|
||||
|
||||
# Stage 1 of the Check requirements pipeline.
|
||||
#
|
||||
# Runs the deterministic Python checks and uploads the structured
|
||||
# results as an artifact. Stage 2 (the agentic workflow defined in
|
||||
# `check-requirements.md`) consumes the artifact on completion.
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
# Auto-trigger on PRs that touch tracked requirement files is disabled
|
||||
# for now while we iterate — testing the workflow_run handoff to the
|
||||
# agentic stage is hard with an auto-trigger. Re-enable once the chain
|
||||
# has been validated end-to-end.
|
||||
# pull_request:
|
||||
# types: [opened, synchronize, reopened]
|
||||
# paths:
|
||||
# - "**/requirements*.txt"
|
||||
# - "homeassistant/package_constraints.txt"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pull_request_number:
|
||||
description: "Pull request number to (re-)check"
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.pull_request_number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deterministic:
|
||||
name: Run deterministic requirement checks
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read # To fetch the PR diff via gh CLI
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
- name: Install script dependencies
|
||||
run: pip install -r script/check_requirements/requirements.txt
|
||||
- name: Collect PR diff
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
mkdir -p deterministic
|
||||
gh pr diff "${PR_NUMBER}" > deterministic/pr.diff
|
||||
- name: Run deterministic checks
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
python -m script.check_requirements \
|
||||
--pr-number "${PR_NUMBER}" \
|
||||
--diff deterministic/pr.diff \
|
||||
--output deterministic/results.json
|
||||
- name: Upload deterministic-results artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: check-requirements-deterministic
|
||||
path: deterministic/results.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Check requirements (dispatcher)
|
||||
|
||||
# Stage 2 of the agentic Check requirements workflow. Runs on completion of
|
||||
# stage 1 (`check-requirements-changes.yml`) and dispatches stage 3
|
||||
# (`check-requirements.lock.yml`)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on: # zizmor: ignore[dangerous-triggers]
|
||||
# workflow_run is safe here: this workflow does not check out PR code or run
|
||||
# any code from the triggering PR. It only resolves the PR number from the
|
||||
# head SHA and dispatches `check-requirements.lock.yml` with that number as
|
||||
# a sanitized string input. The PR code is analysed downstream in the
|
||||
# agentic workflow (`check-requirements.lock.yml`)
|
||||
workflow_run:
|
||||
workflows: ["Check requirements (changes detection)"]
|
||||
types: [completed]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: Dispatch agentic requirements check
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
&& github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write # For triggering the downstream workflow
|
||||
pull-requests: read # For querying PRs by commit SHA
|
||||
steps:
|
||||
- name: Resolve PR number from head SHA and trigger agentic requirements check
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const headBranch = context.payload.workflow_run.head_branch;
|
||||
const headRepository = context.payload.workflow_run.head_repository;
|
||||
const headRepo = headRepository.full_name;
|
||||
// Query the head repository (which may be a fork). When the PR comes
|
||||
// from a fork, the upstream's listPullRequestsAssociatedWithCommit
|
||||
// returns no results for the fork's commit SHA.
|
||||
const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: headRepository.owner.login,
|
||||
repo: headRepository.name,
|
||||
commit_sha: headSha,
|
||||
});
|
||||
const matches = pulls.filter(p =>
|
||||
p.state === 'open'
|
||||
&& p.head.ref === headBranch
|
||||
&& p.head.repo?.full_name === headRepo
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
core.info(`No open PR found for head SHA ${headSha} on ${headRepo}:${headBranch}; nothing to dispatch.`);
|
||||
return;
|
||||
}
|
||||
const defaultBranch = context.payload.workflow_run.repository.default_branch;
|
||||
for (const pr of matches) {
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'check-requirements.lock.yml',
|
||||
ref: defaultBranch,
|
||||
inputs: {
|
||||
pull_request_number: String(pr.number),
|
||||
},
|
||||
});
|
||||
core.info(`Dispatched check-requirements.lock.yml for PR #${pr.number}.`);
|
||||
}
|
||||
+55
-135
@@ -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":"62eb6e3d38092bd041a0c1ddfdaef94cf4b9c694b2d2bcac6cbbecd6810230ca","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"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -22,7 +22,7 @@
|
||||
#
|
||||
# For more information: https://github.github.com/gh-aw/introduction/overview/
|
||||
#
|
||||
# Resolves the deterministic-stage artifact's NEEDS_AGENT checks for changed Python package requirements on PRs targeting the core repo, then posts the final review comment. Triggered by completion of the deterministic workflow. Reads the uploaded artifact from disk, replaces placeholders for any check whose status is `needs_agent`, and posts the merged comment using the PR number recorded inside the artifact itself. Each check kind has a dedicated instruction section below; if the artifact contains a check kind that does not have a section here, the agent fails hard rather than guess.
|
||||
# Checks changed Python package requirements on PRs targeting the core repo (including PRs opened from forks) and verifies licenses match PyPI metadata, source repositories are publicly accessible, PyPI releases were uploaded via automated CI (Trusted Publisher attestation), the package's release pipeline uses OIDC or equivalent automated credentials (not static tokens), and the PR description contains the required links.
|
||||
#
|
||||
# Secrets used:
|
||||
# - COPILOT_GITHUB_TOKEN
|
||||
@@ -46,32 +46,30 @@
|
||||
# - ghcr.io/github/github-mcp-server:v1.0.4
|
||||
# - node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f
|
||||
|
||||
name: "Check requirements (AW)"
|
||||
name: "Check requirements"
|
||||
on:
|
||||
workflow_run:
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
types:
|
||||
- completed
|
||||
workflows:
|
||||
- Check requirements (deterministic)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
aw_context:
|
||||
default: ""
|
||||
description: Agent caller context (used internally by Agentic Workflows).
|
||||
required: false
|
||||
type: string
|
||||
pull_request_number:
|
||||
description: Pull request number to (re-)check
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
group: ${{ github.workflow }}-${{ inputs.pull_request_number }}
|
||||
|
||||
run-name: "Check requirements (AW)"
|
||||
run-name: "Check requirements"
|
||||
|
||||
jobs:
|
||||
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 &&
|
||||
(!(github.event.workflow_run.repository.fork)))
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -94,10 +92,8 @@ jobs:
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }}
|
||||
parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }}
|
||||
env:
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
@@ -110,7 +106,7 @@ jobs:
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_AGENT_VERSION: "1.0.48"
|
||||
GH_AW_INFO_CLI_VERSION: "v0.74.4"
|
||||
GH_AW_INFO_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_INFO_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_INFO_EXPERIMENTAL: "false"
|
||||
GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
|
||||
GH_AW_INFO_STAGED: "false"
|
||||
@@ -187,24 +183,25 @@ jobs:
|
||||
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
|
||||
GH_AW_INPUTS_PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||||
# poutine:ignore untrusted_checkout_exec
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_2df1318dbe2d4011_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_2df1318dbe2d4011_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_2df1318dbe2d4011_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_2df1318dbe2d4011_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_2df1318dbe2d4011_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -233,18 +230,19 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_2df1318dbe2d4011_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_2df1318dbe2d4011_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_2df1318dbe2d4011_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
|
||||
GH_AW_ENGINE_ID: "copilot"
|
||||
GH_AW_INPUTS_PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@@ -263,8 +261,8 @@ jobs:
|
||||
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
|
||||
GH_AW_INPUTS_PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||||
GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` — run `safeoutputs --help` to see available tools'
|
||||
GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@@ -284,8 +282,8 @@ jobs:
|
||||
GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
|
||||
GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
|
||||
GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
|
||||
GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST,
|
||||
GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
|
||||
GH_AW_INPUTS_PULL_REQUEST_NUMBER: process.env.GH_AW_INPUTS_PULL_REQUEST_NUMBER,
|
||||
GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST
|
||||
}
|
||||
});
|
||||
- name: Validate prompt placeholders
|
||||
@@ -316,17 +314,12 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
agent:
|
||||
needs:
|
||||
- activation
|
||||
- extract_pr_number
|
||||
needs: activation
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: "gh-aw-copilot-${{ github.workflow }}"
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
GH_AW_ASSETS_ALLOWED_EXTS: ""
|
||||
@@ -359,7 +352,7 @@ jobs:
|
||||
trace-id: ${{ needs.activation.outputs.setup-trace-id }}
|
||||
parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
|
||||
env:
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
@@ -381,15 +374,6 @@ jobs:
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
- if: github.event.workflow_run.conclusion == 'success'
|
||||
name: Download deterministic-results artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/gh-aw/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Configure Git credentials
|
||||
env:
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
@@ -449,19 +433,21 @@ jobs:
|
||||
- name: Download container images
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.46 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46 ghcr.io/github/gh-aw-firewall/squid:0.25.46 ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388 ghcr.io/github/github-mcp-server:v1.0.4 node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f
|
||||
- name: Generate Safe Outputs Config
|
||||
env:
|
||||
GH_AW_INPUT_PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||||
run: |
|
||||
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
|
||||
mkdir -p /tmp/gh-aw/safeoutputs
|
||||
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << GH_AW_SAFE_OUTPUTS_CONFIG_c7878b8b9775118a_EOF
|
||||
{"add_comment":{"max":1,"target":"${GH_AW_INPUT_PULL_REQUEST_NUMBER}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_c7878b8b9775118a_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
{
|
||||
"description_suffixes": {
|
||||
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ needs.extract_pr_number.outputs.pr_number }}. Supports reply_to_id for discussion threading."
|
||||
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ inputs.pull_request_number }}. Supports reply_to_id for discussion threading."
|
||||
},
|
||||
"repo_params": {},
|
||||
"dynamic_tools": []
|
||||
@@ -647,7 +633,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_103328ae7b98b0c7_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -657,7 +643,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": "context,repos,issues,pull_requests"
|
||||
},
|
||||
"guard-policies": {
|
||||
"allow-only": {
|
||||
@@ -691,7 +677,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
GH_AW_MCP_CONFIG_103328ae7b98b0c7_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -890,7 +876,7 @@ jobs:
|
||||
if [ ! -f /tmp/gh-aw/agent_output.json ]; then
|
||||
echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
|
||||
fi
|
||||
- if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
- if: always()
|
||||
name: Verify agent produced an add_comment safe-output
|
||||
run: |-
|
||||
OUTPUT=/tmp/gh-aw/agent_output.json
|
||||
@@ -938,7 +924,6 @@ jobs:
|
||||
- activation
|
||||
- agent
|
||||
- detection
|
||||
- extract_pr_number
|
||||
- safe_outputs
|
||||
if: >
|
||||
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
|
||||
@@ -968,7 +953,7 @@ jobs:
|
||||
trace-id: ${{ needs.activation.outputs.setup-trace-id }}
|
||||
parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
|
||||
env:
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
@@ -992,7 +977,7 @@ jobs:
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_NOOP_MAX: "1"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
|
||||
GH_AW_NOOP_REPORT_AS_ISSUE: "true"
|
||||
@@ -1008,7 +993,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
|
||||
GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
|
||||
@@ -1025,7 +1010,7 @@ jobs:
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1039,7 +1024,7 @@ jobs:
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1053,7 +1038,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
|
||||
GH_AW_WORKFLOW_ID: "check-requirements"
|
||||
@@ -1107,7 +1092,7 @@ jobs:
|
||||
trace-id: ${{ needs.activation.outputs.setup-trace-id }}
|
||||
parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
|
||||
env:
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
@@ -1175,8 +1160,8 @@ jobs:
|
||||
if: always() && steps.detection_guard.outputs.run_detection == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
WORKFLOW_NAME: "Check requirements (AW)"
|
||||
WORKFLOW_DESCRIPTION: "Resolves the deterministic-stage artifact's NEEDS_AGENT checks for changed Python package requirements on PRs targeting the core repo, then posts the final review comment. Triggered by completion of the deterministic workflow. Reads the uploaded artifact from disk, replaces placeholders for any check whose status is `needs_agent`, and posts the merged comment using the PR number recorded inside the artifact itself. Each check kind has a dedicated instruction section below; if the artifact contains a check kind that does not have a section here, the agent fails hard rather than guess."
|
||||
WORKFLOW_NAME: "Check requirements"
|
||||
WORKFLOW_DESCRIPTION: "Checks changed Python package requirements on PRs targeting the core repo (including PRs opened from forks) and verifies licenses match PyPI metadata, source repositories are publicly accessible, PyPI releases were uploaded via automated CI (Trusted Publisher attestation), the package's release pipeline uses OIDC or equivalent automated credentials (not static tokens), and the PR description contains the required links."
|
||||
HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
|
||||
with:
|
||||
script: |
|
||||
@@ -1283,76 +1268,11 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
|
||||
outputs:
|
||||
pr_number: ${{ steps.extract.outputs.pr_number }}
|
||||
steps:
|
||||
- name: Configure GH_HOST for enterprise compatibility
|
||||
id: ghes-host-config
|
||||
shell: bash
|
||||
run: |
|
||||
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
|
||||
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
|
||||
GH_HOST="${GITHUB_SERVER_URL#https://}"
|
||||
GH_HOST="${GH_HOST#http://}"
|
||||
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
|
||||
- name: Download deterministic-results artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
- name: Extract PR number from artifact
|
||||
id: extract
|
||||
run: |
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
pre_activation:
|
||||
runs-on: ubuntu-slim
|
||||
outputs:
|
||||
activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
|
||||
matched_command: ''
|
||||
setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
|
||||
setup-span-id: ${{ steps.setup.outputs.span-id }}
|
||||
setup-trace-id: ${{ steps.setup.outputs.trace-id }}
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
env:
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Check team membership for workflow
|
||||
id: check_membership
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
setupGlobals(core, github, context, exec, io, getOctokit);
|
||||
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
|
||||
await main();
|
||||
|
||||
safe_outputs:
|
||||
needs:
|
||||
- activation
|
||||
- agent
|
||||
- detection
|
||||
- extract_pr_number
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
@@ -1370,7 +1290,7 @@ jobs:
|
||||
GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
|
||||
GH_AW_ENGINE_VERSION: "1.0.48"
|
||||
GH_AW_WORKFLOW_ID: "check-requirements"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements"
|
||||
outputs:
|
||||
code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
|
||||
code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
|
||||
@@ -1390,7 +1310,7 @@ jobs:
|
||||
trace-id: ${{ needs.activation.outputs.setup-trace-id }}
|
||||
parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
|
||||
env:
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements"
|
||||
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
@@ -1425,7 +1345,7 @@ jobs:
|
||||
GH_AW_ALLOWED_DOMAINS: "*.pythonhosted.org,anaconda.org,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,binstar.org,bootstrap.pypa.io,conda.anaconda.org,conda.binstar.org,files.pythonhosted.org,github.com,host.docker.internal,pip.pypa.io,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.npmjs.org,repo.anaconda.com,repo.continuum.io,telemetry.enterprise.githubcopilot.com"
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ needs.extract_pr_number.outputs.pr_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ inputs.pull_request_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -1,63 +1,33 @@
|
||||
---
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Check requirements (deterministic)"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pull_request_number:
|
||||
description: "Pull request number to (re-)check"
|
||||
required: true
|
||||
type: number
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
network:
|
||||
allowed:
|
||||
- python
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [default, actions]
|
||||
toolsets: [default]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
max: 1
|
||||
target: "${{ needs.extract_pr_number.outputs.pr_number }}"
|
||||
needs:
|
||||
- extract_pr_number
|
||||
jobs:
|
||||
extract_pr_number:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
outputs:
|
||||
pr_number: ${{ steps.extract.outputs.pr_number }}
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract PR number from artifact
|
||||
id: extract
|
||||
run: |
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
target: ${{ inputs.pull_request_number }}
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
group: ${{ github.workflow }}-${{ inputs.pull_request_number }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/gh-aw/deterministic
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
post-steps:
|
||||
- name: Verify agent produced an add_comment safe-output
|
||||
if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
if: always()
|
||||
run: |
|
||||
OUTPUT=/tmp/gh-aw/agent_output.json
|
||||
if [ ! -f "${OUTPUT}" ]; then
|
||||
@@ -71,308 +41,376 @@ post-steps:
|
||||
exit 1
|
||||
fi
|
||||
description: >
|
||||
Resolves the deterministic-stage artifact's NEEDS_AGENT checks for changed
|
||||
Python package requirements on PRs targeting the core repo, then posts the
|
||||
final review comment. Triggered by completion of the deterministic workflow.
|
||||
Reads the uploaded artifact from disk, replaces placeholders for any check
|
||||
whose status is `needs_agent`, and posts the merged comment using the PR
|
||||
number recorded inside the artifact itself. Each check kind has a dedicated
|
||||
instruction section below; if the artifact contains a check kind that does
|
||||
not have a section here, the agent fails hard rather than guess.
|
||||
Checks changed Python package requirements on PRs targeting the core repo
|
||||
(including PRs opened from forks) and verifies licenses match PyPI metadata, source
|
||||
repositories are publicly accessible, PyPI releases were uploaded via
|
||||
automated CI (Trusted Publisher attestation), the package's release pipeline
|
||||
uses OIDC or equivalent automated credentials (not static tokens), and the PR
|
||||
description contains the required links.
|
||||
---
|
||||
|
||||
# Check requirements (AW)
|
||||
# Check requirements
|
||||
|
||||
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 the Home Assistant project. Your job is to
|
||||
review changes to Python package requirements and verify they meet the project's
|
||||
standards.
|
||||
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
## Context
|
||||
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
- Home Assistant uses `requirements_all.txt` (all integration packages),
|
||||
`requirements.txt` (core packages), `requirements_test.txt` (test
|
||||
dependencies), and `requirements_test_all.txt` (all test dependencies) to
|
||||
declare Python dependencies.
|
||||
- Each integration lists its packages in `homeassistant/components/<name>/manifest.json`
|
||||
under the `requirements` field.
|
||||
- Allowed licenses are maintained in `script/licenses.py` under
|
||||
`OSI_APPROVED_LICENSES_SPDX` (SPDX identifiers) and `OSI_APPROVED_LICENSES`
|
||||
(classifier strings).
|
||||
|
||||
The JSON has this shape:
|
||||
## Step 1 — Identify Changed Packages
|
||||
|
||||
- `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).
|
||||
This workflow is triggered via `workflow_dispatch`. The PR number to check is
|
||||
**#${{ inputs.pull_request_number }}**. Use that PR number for **every** GitHub
|
||||
API call in the steps below (fetching the diff, the PR body, etc.). Do **not**
|
||||
rely on `github.event.pull_request` — it is not populated for
|
||||
`workflow_dispatch` runs.
|
||||
|
||||
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.
|
||||
Use the GitHub tool to fetch the PR diff for that PR number. Look for
|
||||
lines that were added (`+`) or removed (`-`) in **any** of these files:
|
||||
- `requirements.txt`
|
||||
- `requirements_all.txt`
|
||||
- `requirements_test.txt`
|
||||
- `requirements_test_all.txt`
|
||||
- `homeassistant/package_constraints.txt`
|
||||
- `pyproject.toml`
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
For each changed line that contains a package pin (e.g. `SomePackage==1.2.3`),
|
||||
classify it as:
|
||||
- **New package**: the package name appears only in `+` lines, with no
|
||||
corresponding `-` line for the same package name.
|
||||
- **Version bump**: the same package name appears in both `+` lines (new
|
||||
version) and `-` lines (old version), with different version numbers.
|
||||
|
||||
For each `package` in `packages`:
|
||||
Record the **old version** and **new version** for every version bump — you
|
||||
will need these values in Step 4.
|
||||
|
||||
For each `(check_kind, result)` in `package.checks` where
|
||||
`result.status == "needs_agent"`:
|
||||
|
||||
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:
|
||||
## Step 2 — Check License via PyPI
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
For each new or bumped package:
|
||||
|
||||
❌ 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.
|
||||
```
|
||||
1. Fetch `https://pypi.org/pypi/{package_name}/json` (use the exact
|
||||
package name as it appears on the requirements file).
|
||||
2. From the JSON response, extract:
|
||||
- `info.license` — free-text license field
|
||||
- `info.license_expression` — SPDX expression (if present)
|
||||
- `info.classifiers` — filter for entries starting with `"License ::"`,
|
||||
then normalize each match the same way as `script/licenses.py` by
|
||||
extracting the final ` :: ` segment (for example,
|
||||
`"License :: OSI Approved :: MIT License"` → `"MIT License"`).
|
||||
3. Determine if the license is in the approved list from `script/licenses.py`:
|
||||
- SPDX identifiers: compare against `OSI_APPROVED_LICENSES_SPDX`
|
||||
- Normalized classifier strings: compare against `OSI_APPROVED_LICENSES`
|
||||
4. Flag a package as ❌ if the license is unknown, missing, or not in the
|
||||
approved list. Flag as ⚠️ if the license information is ambiguous or cannot
|
||||
be definitively determined.
|
||||
|
||||
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 2b — Verify PyPI Release Was Uploaded by CI
|
||||
|
||||
## Step 3 — Post the comment
|
||||
For each new or bumped package, verify that the release on PyPI was published
|
||||
automatically by a CI pipeline (via OIDC Trusted Publisher), not uploaded
|
||||
manually.
|
||||
|
||||
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`.
|
||||
1. Fetch the PyPI JSON for the specific version being introduced or bumped:
|
||||
`https://pypi.org/pypi/{package_name}/{version}/json`
|
||||
2. Inspect the `urls` array in the response. For each distribution file (wheel
|
||||
or sdist), note the filename.
|
||||
3. For each filename, attempt to fetch the PyPI provenance attestation:
|
||||
`https://pypi.org/integrity/{package_name}/{version}/{filename}/provenance`
|
||||
- If the response is HTTP 200 and contains a valid attestation object,
|
||||
inspect `attestation_bundles[*].publisher`. A Trusted Publisher attestation
|
||||
will have a `kind` identifying the CI system (e.g. `"GitHub Actions"`,
|
||||
`"GitLab"`) and a `repository` or `project` field matching the source
|
||||
repository.
|
||||
- If at least one distribution file has a valid Trusted Publisher attestation,
|
||||
mark ✅ CI-uploaded.
|
||||
- If no attestation is found for any file (404 for all), mark ⚠️ — "Release
|
||||
has no provenance attestation; it may have been uploaded manually".
|
||||
- If an attestation exists but the `publisher` does not identify a recognized
|
||||
CI system or Trusted Publisher, mark ⚠️ — "Attestation present but
|
||||
publisher cannot be verified as automated CI".
|
||||
|
||||
If the artifact's top-level `needs_agent` is `false` (no checks need
|
||||
you), emit `rendered_comment` unchanged.
|
||||
Note: if PyPI returns an error fetching the per-version JSON, fall back to the
|
||||
latest JSON (`https://pypi.org/pypi/{package_name}/json`) and look up the
|
||||
specific version in the `releases` dict.
|
||||
|
||||
## Check instructions
|
||||
## Step 3 — Identify Repository URL
|
||||
|
||||
### Check kind: `repo_public`
|
||||
For each new or bumped package:
|
||||
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
1. From the PyPI JSON at `info.project_urls`, find the source repository URL
|
||||
(keys such as `"Source"`, `"Homepage"`, `"Repository"`, or `"Source Code"`).
|
||||
2. Record that repository URL for later checks.
|
||||
3. If no suitable repository URL is present, mark ❌ with a note that the
|
||||
source repository URL is missing and cannot be verified.
|
||||
|
||||
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.
|
||||
## Step 4 — Check PR 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.
|
||||
Read the PR body from the GitHub API for PR
|
||||
#${{ inputs.pull_request_number }}. Extract all URLs present in the PR body.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
### 4a — New packages: repository link required
|
||||
|
||||
Verify the PR description contains the right link for the change.
|
||||
For **new packages** (brand-new dependency not previously in any requirements
|
||||
file): the PR description must contain a link that points to the package's
|
||||
**source repository** as identified in Step 3 (the URL recorded from
|
||||
`info.project_urls`). A PyPI page link alone is **not** acceptable — the link
|
||||
must point directly to the source repository (e.g. a GitHub or GitLab URL).
|
||||
|
||||
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>.`
|
||||
- If a URL in the PR body matches (or is a sub-path of) the source repository
|
||||
URL identified via PyPI, mark ✅.
|
||||
- If the PR body contains a source repository URL that does **not** match the
|
||||
repository URL found in the package's PyPI metadata (`info.project_urls`),
|
||||
mark ❌ — "PR description links to `<pr_url>` but PyPI reports the source
|
||||
repository as `<pypi_repo_url>`; please use the correct repository URL."
|
||||
- If no source repository URL is present in the PR body at all, mark ❌ —
|
||||
"PR description must link to the source repository at `<repo_url>` (found
|
||||
via PyPI). A PyPI page link is not sufficient."
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
### 4b — Version bumps: changelog or diff link matching the bump
|
||||
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
For **version bumps**: the PR description must contain a link to a changelog,
|
||||
release notes page, or a diff/comparison URL that references the **exact
|
||||
versions** being bumped (old → new) as recorded in the diff from Step 1.
|
||||
|
||||
For each package needing inspection, determine the source repository
|
||||
host from `package.repo_url`, then apply the corresponding checklist.
|
||||
Checks to perform for each bumped package (old version = X, new version = Y):
|
||||
1. Extract all URLs from the PR body that contain the repository's domain or
|
||||
path (as identified in Step 3).
|
||||
2. Verify that at least one such URL includes both the old version (X) and the
|
||||
new version (Y) in some form — e.g. a GitHub compare URL like
|
||||
`compare/vX...vY`, a releases URL mentioning version Y, or a
|
||||
`CHANGELOG.md` anchor referencing Y.
|
||||
3. Confirm the link's version range matches the actual bump in the diff. If
|
||||
the link references versions different from X → Y (for example, the PR
|
||||
bumps `1.2.3 → 1.3.0` but the link points to `compare/v1.2.0...v1.2.4`),
|
||||
the link does not match the bump.
|
||||
|
||||
#### GitHub repositories (`github.com`)
|
||||
Outcome:
|
||||
- ✅ — a URL pointing to the correct repo with version references that match
|
||||
the exact bump (X → Y).
|
||||
- ❌ — no changelog/diff link is found, or the link does not match the actual
|
||||
bump (X → Y). Explain what was found and what is expected.
|
||||
|
||||
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.
|
||||
## Step 5 — Verify Source Repository is Publicly Accessible
|
||||
|
||||
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
Before inspecting the release pipeline, confirm that the source repository
|
||||
identified in Step 3 is publicly reachable.
|
||||
|
||||
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.
|
||||
For each new or bumped package:
|
||||
|
||||
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
|
||||
1. Use the source repository URL recorded in Step 3.
|
||||
2. If no repository URL was found in `info.project_urls`, mark ❌ — "No source
|
||||
repository URL found in PyPI metadata; a public source repository is
|
||||
required."
|
||||
3. If a repository URL was found, perform a GET request to that URL (using
|
||||
web-fetch). If the response is HTTP 200 and returns a publicly accessible
|
||||
page (not a login redirect or error page), mark ✅.
|
||||
4. If the response is non-200, the URL redirects to a login/authentication page,
|
||||
or the repository appears private or unavailable, mark ❌ — "Source
|
||||
repository at `<repo_url>` is not publicly accessible. Home Assistant
|
||||
requires all dependencies to have publicly available source code." **Do not
|
||||
proceed with the release pipeline check (Step 6) for this package.**
|
||||
|
||||
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.`
|
||||
## Step 6 — Check Release Pipeline Sanity
|
||||
|
||||
### Check kind: `async_blocking`
|
||||
For each new or bumped package, determine the source repository host from the
|
||||
URL identified in Step 3, then inspect whether the project's release/publish CI
|
||||
workflow is sane. The checks differ by hosting provider.
|
||||
|
||||
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.
|
||||
### GitHub repositories (`github.com`)
|
||||
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
1. Using the GitHub API, list the workflows in the source repository:
|
||||
`GET /repos/{owner}/{repo}/actions/workflows`
|
||||
2. Identify any workflow whose name or filename suggests publishing to PyPI
|
||||
(e.g., contains "release", "publish", "pypi", or "deploy").
|
||||
3. Fetch the workflow file content and check the following:
|
||||
a. **Trigger sanity**: The publish job should be triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job — **not** solely
|
||||
by `workflow_dispatch` with no additional guards. A `workflow_dispatch`
|
||||
trigger alongside other triggers is acceptable. Mark ❌ if the only trigger
|
||||
is manual `workflow_dispatch` with no environment protection rules.
|
||||
b. **OIDC / Trusted Publisher**: The workflow should use OIDC-based publishing.
|
||||
Look for `id-token: write` permission and one of:
|
||||
- `pypa/gh-action-pypi-publish` action
|
||||
- `actions/attest-build-provenance` action
|
||||
- Any step that sets `TWINE_PASSWORD` from `secrets.PYPI_TOKEN` directly
|
||||
(treat this as a static long-lived API token rather than OIDC).
|
||||
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined.
|
||||
If a static secret token is the only credential, mark ⚠️ for version
|
||||
bumps (the package was already accepted at a previous version; suggest
|
||||
the upstream maintainer switch to OIDC / Trusted Publisher for better
|
||||
security) and ❌ for new packages.
|
||||
c. **No manual upload bypass**: Verify there is no step that calls
|
||||
`twine upload` or `pip upload` outside of a properly gated job (e.g., one
|
||||
that requires an environment approval). Flag ⚠️ if such steps exist.
|
||||
4. If no publish workflow is found in the repository, mark ⚠️ — "No publish
|
||||
workflow found; it is unclear how this package is released to PyPI."
|
||||
|
||||
- `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.
|
||||
### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
1. Use the GitLab REST API to list CI/CD pipeline configuration files. First
|
||||
resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`
|
||||
and note the `id` field.
|
||||
2. Fetch the repository's `.gitlab-ci.yml` (and any included files) using
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`
|
||||
(use web-fetch for public repos).
|
||||
3. Identify any job whose name or `stage` suggests publishing to PyPI
|
||||
(e.g., "publish", "deploy", "release", "pypi").
|
||||
4. For each such job, check:
|
||||
a. **Trigger sanity**: The job should run only on tag pipelines (`only: tags`
|
||||
or `rules: - if: $CI_COMMIT_TAG`) or on protected branches — **not**
|
||||
solely on manual triggers (`when: manual`) with no additional protection.
|
||||
Mark ❌ if the only trigger is manual with no environment or protected-branch
|
||||
guard.
|
||||
b. **Automated credentials**: The job should use GitLab's OIDC ID token
|
||||
(`id_tokens:` block) and `pypa/gh-action-pypi-publish` equivalent, or
|
||||
reference `secrets.PYPI_TOKEN` / `$PYPI_TOKEN` injected from GitLab CI/CD
|
||||
protected variables. Flag ❌ if the token is hard-coded or unprotected.
|
||||
Mark ✅ if OIDC is used, ⚠️ if the method cannot be determined. If a
|
||||
protected static token is the only credential, mark ⚠️ for version bumps
|
||||
(suggest the upstream maintainer switch to OIDC / Trusted Publisher for
|
||||
better security) and ❌ for new packages.
|
||||
c. **No manual upload bypass**: Flag ⚠️ if any job calls `twine upload`
|
||||
without being behind a protected-variable or environment guard.
|
||||
5. If no publish job is found, mark ⚠️ — "No publish job found in .gitlab-ci.yml;
|
||||
it is unclear how this package is released to PyPI."
|
||||
|
||||
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}`).
|
||||
### Other code hosting providers
|
||||
|
||||
- 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.
|
||||
For repositories hosted on platforms other than GitHub or GitLab (e.g.,
|
||||
Bitbucket, Codeberg, Gitea, Sourcehut):
|
||||
1. Use web-fetch to retrieve the repository's root page and look for any
|
||||
publicly visible CI configuration files (e.g., `.circleci/config.yml`,
|
||||
`Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`,
|
||||
`.builds/*.yml` for Sourcehut).
|
||||
2. Apply the same conceptual checks as above:
|
||||
- Does publishing run on automated triggers (tags/releases), not solely
|
||||
manual ones?
|
||||
- Are credentials injected by the CI system (not hard-coded)?
|
||||
- Is there a `twine upload` or equivalent step that could be run manually?
|
||||
3. If no CI configuration can be retrieved, mark ⚠️ — "Release pipeline could
|
||||
not be inspected; hosting provider is not GitHub or GitLab."
|
||||
|
||||
If the library is **sync-only** (no `async def` in its public modules
|
||||
and no async framework dependency) → ✅
|
||||
`Sync-only library; Home Assistant integrations must wrap calls in an
|
||||
executor.` *This verdict is the same in both modes.*
|
||||
## Step 7 — Post a Review Comment
|
||||
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
**Always** post a review comment using `add_comment`, regardless of whether
|
||||
packages pass or fail. Use the following structure:
|
||||
|
||||
Inspect **every `async def` in the public modules** for blocking
|
||||
patterns. Walk transitively into helpers the async functions call.
|
||||
**Note on deduplication**: The workflow automatically updates any previous
|
||||
requirements-check comment on the PR in place (preserving its position in the
|
||||
thread). If no previous comment exists, the newly created comment is kept as-is.
|
||||
You do not need to search for or update previous comments yourself.
|
||||
|
||||
#### Step 2b — Mode: version bump (`old_version` is a string)
|
||||
### Comment structure
|
||||
|
||||
Fetch the diff between the two tags and review **only changed lines**:
|
||||
Begin every comment with the HTML marker `<!-- requirements-check -->` on its
|
||||
own line (this is used by the workflow to find the previous comment and update
|
||||
it on the next run).
|
||||
|
||||
- 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`.
|
||||
### 7a — Overall summary line
|
||||
|
||||
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.
|
||||
Begin the comment with a single summary line, before anything else:
|
||||
|
||||
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.
|
||||
- If everything passed: `All requirements checks passed. ✅`
|
||||
- If there are failures or warnings: `⚠️ Some checks require attention — see the details below.`
|
||||
|
||||
#### Step 3 — Blocking patterns to look for
|
||||
### 7b — Summary table
|
||||
|
||||
In both modes, the patterns to flag inside `async def` bodies are:
|
||||
Render a compact table where every check column contains **only the status
|
||||
icon** (✅, ⚠️, or ❌). No explanatory text belongs inside the table cells —
|
||||
all detail goes in the per-package sections below.
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
|
||||
`AsyncClient`), `pycurl`.
|
||||
- `time.sleep(` (must be `await asyncio.sleep(`).
|
||||
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
|
||||
blocking `select.select`.
|
||||
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
|
||||
non-trivial sizes (small one-shot reads during import are
|
||||
acceptable; reads/writes on the request path are not — prefer
|
||||
`aiofiles` / executor).
|
||||
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
|
||||
`pymongo` (sync client), `redis.Redis` (sync client).
|
||||
- `subprocess.run` / `subprocess.call` / `os.system` (must be
|
||||
`asyncio.create_subprocess_*`).
|
||||
Use `—` (em dash) when a check was skipped (e.g. Release Pipeline is skipped
|
||||
when the repository is not publicly accessible).
|
||||
|
||||
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.
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
#### Step 4 — Verdict
|
||||
| Package | Type | Old→New | License | Repo Public | CI Upload | Release Pipeline | PR Link |
|
||||
|---------|------|---------|---------|-------------|-----------|------------------|---------|
|
||||
| PackageA | bump | 1.2.3→1.3.0 | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| PackageB | new | —→4.5.6 | ❌ | ✅ | ⚠️ | ⚠️ | ❌ |
|
||||
| PackageC | bump | 2.0.0→2.1.0 | ✅ | ❌ | — | — | ❌ |
|
||||
```
|
||||
|
||||
- ✅ — 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.
|
||||
### 7c — Per-package detail sections
|
||||
|
||||
After the table, add one collapsible `<details>` block per package.
|
||||
|
||||
- If **all checks passed** for that package, render the block **collapsed**
|
||||
(no `open` attribute) so the comment stays concise.
|
||||
- If **any check failed or produced a warning**, render the block **open**
|
||||
(`<details open>`) so the contributor sees the issues immediately.
|
||||
|
||||
Each block must include the full detail for every check: the license found, the
|
||||
repository URL, whether a provenance attestation was found, the release
|
||||
pipeline findings, and the PR link found (or missing, or mismatched with the
|
||||
actual bump). For failed or warned checks, explain exactly what the contributor
|
||||
must fix, including the expected source repository URL, expected version range,
|
||||
etc.
|
||||
|
||||
Template (repeat for each package):
|
||||
|
||||
```
|
||||
<details open>
|
||||
<summary><strong>PackageB 📦 new —→4.5.6</strong></summary>
|
||||
|
||||
- **License**: ❌ License is `UNKNOWN` — not in the approved list. Check PyPI metadata and `script/licenses.py`.
|
||||
- **Repository Public**: ✅ https://github.com/example/packageb is publicly accessible.
|
||||
- **CI Upload**: ⚠️ No provenance attestation found for any distribution file. The release may have been uploaded manually.
|
||||
- **Release Pipeline**: ⚠️ No publish workflow found in the repository; it is unclear how this package is released to PyPI.
|
||||
- **PR Link**: ❌ PR description must link to the source repository at https://github.com/example/packageb (a PyPI page link is not sufficient).
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
Collapsed example (all checks passed):
|
||||
|
||||
```
|
||||
<details>
|
||||
<summary><strong>PackageA 📦 bump 1.2.3→1.3.0</strong></summary>
|
||||
|
||||
- **License**: ✅ MIT
|
||||
- **Repository Public**: ✅ https://github.com/example/packagea
|
||||
- **CI Upload**: ✅ Trusted Publisher attestation found (GitHub Actions).
|
||||
- **Release Pipeline**: ✅ OIDC via `pypa/gh-action-pypi-publish`; triggered on `release: published`; `environment: release` gate.
|
||||
- **PR Link**: ✅ https://github.com/example/packagea/compare/v1.2.3...v1.3.0
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
## 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 and helpful. Provide direct links where possible so the
|
||||
contributor can quickly fix the issue.
|
||||
- If PyPI returns an error for a package, mention that it could not be found and
|
||||
suggest the contributor verify the package name.
|
||||
- For packages that only appear in `homeassistant/package_constraints.txt` or
|
||||
`pyproject.toml` without being tied to a specific integration, the PR
|
||||
description link requirement still applies.
|
||||
- When checking test-only packages (from `requirements_test.txt` or
|
||||
`requirements_test_all.txt`), apply the same license, repository, and PR
|
||||
description checks as for production dependencies.
|
||||
- A package that appears in both a production file and a test file should only
|
||||
be reported once; use the production file entry as the canonical one.
|
||||
- This workflow is invoked exclusively via `workflow_dispatch`. The stage-1
|
||||
workflow `Check requirements (changes detection)` runs on `pull_request` with
|
||||
a paths filter on the tracked requirements files, and its completion triggers
|
||||
the dispatcher (`Check requirements (dispatcher)`) which calls this workflow
|
||||
with the PR number. Members can also dispatch this workflow manually with the
|
||||
PR number to re-run the check after updating the PR description or fixing
|
||||
issues without changing any requirements files. On a retrigger the existing
|
||||
comment is updated in place so there is always exactly one requirements-check
|
||||
comment in the PR.
|
||||
|
||||
Vendored
+3
-3
@@ -132,7 +132,7 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all production Requirements",
|
||||
"label": "Install all Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements_all.txt",
|
||||
"group": {
|
||||
@@ -146,9 +146,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all (test & production) Requirements",
|
||||
"label": "Install all Test Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements_all.txt -r requirements_test.txt",
|
||||
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
|
||||
Generated
+2
-4
@@ -466,8 +466,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/electrasmart/ @jafar-atili
|
||||
/homeassistant/components/electric_kiwi/ @mikey0000
|
||||
/tests/components/electric_kiwi/ @mikey0000
|
||||
/homeassistant/components/electrolux/ @electrolux-oss
|
||||
/tests/components/electrolux/ @electrolux-oss
|
||||
/homeassistant/components/elevenlabs/ @sorgfresser
|
||||
/tests/components/elevenlabs/ @sorgfresser
|
||||
/homeassistant/components/elgato/ @frenck
|
||||
@@ -1540,8 +1538,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/saj/ @fredericvl
|
||||
/homeassistant/components/samsung_infrared/ @lmaertin
|
||||
/tests/components/samsung_infrared/ @lmaertin
|
||||
/homeassistant/components/samsungtv/ @chemelli74
|
||||
/tests/components/samsungtv/ @chemelli74
|
||||
/homeassistant/components/samsungtv/ @chemelli74 @epenet
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
/homeassistant/components/sanix/ @tomaszsluszniak
|
||||
/tests/components/sanix/ @tomaszsluszniak
|
||||
/homeassistant/components/satel_integra/ @Tommatheussen
|
||||
|
||||
@@ -81,10 +81,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except AirOSKeyDataMissingError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryError("key_data_missing") from err
|
||||
except Exception as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryError("unknown") from err
|
||||
|
||||
airos_class: type[AirOS8 | AirOS6] = (
|
||||
|
||||
@@ -91,7 +91,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotAuthenticate as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
|
||||
@@ -51,11 +51,13 @@
|
||||
"advanced": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]"
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]"
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
|
||||
},
|
||||
@@ -118,11 +120,13 @@
|
||||
"advanced": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"prompt_caching": "Caching strategy"
|
||||
"prompt_caching": "Caching strategy",
|
||||
"temperature": "Temperature"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "The model to serve the responses.",
|
||||
"prompt_caching": "Optimize your API cost and response times based on your usage."
|
||||
"prompt_caching": "Optimize your API cost and response times based on your usage.",
|
||||
"temperature": "Control the randomness of the response, trading off between creativity and coherence."
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
},
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["py-aosmith==1.0.18"]
|
||||
"requirements": ["py-aosmith==1.0.17"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Config flow to configure the Arcam FMJ component."""
|
||||
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.client import Client, ConnectionFailed
|
||||
from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -31,19 +29,26 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
async def _async_try_connect(self, host: str, port: int) -> None:
|
||||
"""Verify the device is reachable."""
|
||||
async def _async_check_and_create(self, host: str, port: int) -> ConfigFlowResult:
|
||||
client = Client(host, port)
|
||||
try:
|
||||
await client.start()
|
||||
except ConnectionFailed:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
finally:
|
||||
await client.stop()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({host})",
|
||||
data={CONF_HOST: host, CONF_PORT: port},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
uuid = await get_uniqueid_from_host(
|
||||
async_get_clientsession(self.hass), user_input[CONF_HOST]
|
||||
@@ -53,36 +58,18 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], uuid
|
||||
)
|
||||
|
||||
try:
|
||||
await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
except socket.gaierror:
|
||||
errors["base"] = "invalid_host"
|
||||
except TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except ConnectionRefusedError:
|
||||
errors["base"] = "connection_refused"
|
||||
except ConnectionFailed, OSError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
|
||||
data={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
)
|
||||
return await self._async_check_and_create(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
|
||||
fields = {
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
schema = vol.Schema(fields)
|
||||
if user_input is not None:
|
||||
schema = self.add_suggested_values_to_schema(schema, user_input)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -92,10 +79,7 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.context["title_placeholders"] = placeholders
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({self.host})",
|
||||
data={CONF_HOST: self.host, CONF_PORT: self.port},
|
||||
)
|
||||
return await self._async_check_and_create(self.host, self.port)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm", description_placeholders=placeholders
|
||||
@@ -113,11 +97,6 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
await self._async_set_unique_id_and_update(host, port, uuid)
|
||||
|
||||
try:
|
||||
await self._async_try_connect(host, port)
|
||||
except ConnectionFailed, OSError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.port = DEFAULT_PORT
|
||||
return await self.async_step_confirm()
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"connection_refused": "Host refused connection",
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
|
||||
},
|
||||
"flow_title": "{host}",
|
||||
"step": {
|
||||
"confirm": {
|
||||
|
||||
@@ -82,7 +82,7 @@ rules:
|
||||
comment: |
|
||||
This integration does not have any entities that should disabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
|
||||
@@ -67,7 +67,7 @@ rules:
|
||||
comment: |
|
||||
Only one entity type (device_tracker) is created, making this not applicable.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
|
||||
@@ -51,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_endpoint_url",
|
||||
|
||||
@@ -72,7 +72,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
|
||||
@@ -75,13 +75,11 @@ def handle_backup_errors[_R, **P](
|
||||
err.message,
|
||||
exc_info=True,
|
||||
)
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupAgentError(
|
||||
f"Error during backup operation in {func.__name__}:"
|
||||
f" Status {err.status_code}, message: {err.message}"
|
||||
) from err
|
||||
except ServiceRequestError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupAgentError(
|
||||
f"Timeout during backup operation in {func.__name__}"
|
||||
) from err
|
||||
@@ -92,7 +90,6 @@ def handle_backup_errors[_R, **P](
|
||||
err,
|
||||
exc_info=True,
|
||||
)
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupAgentError(
|
||||
f"Error during backup operation in {func.__name__}: {err}"
|
||||
) from err
|
||||
@@ -121,7 +118,6 @@ class AzureStorageBackupAgent(BackupAgent):
|
||||
"""Download a backup file."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
download_stream = await self._client.download_blob(blob.name)
|
||||
return download_stream.chunks()
|
||||
@@ -159,7 +155,6 @@ class AzureStorageBackupAgent(BackupAgent):
|
||||
"""Delete a backup file."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
await self._client.delete_blob(blob.name)
|
||||
|
||||
@@ -186,7 +181,6 @@ class AzureStorageBackupAgent(BackupAgent):
|
||||
"""Return a backup."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"]))
|
||||
|
||||
@@ -89,7 +89,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except exception.MissingAccountData as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
|
||||
@@ -96,7 +96,7 @@ rules:
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
|
||||
@@ -67,9 +67,6 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed using the provided key ID and application key."
|
||||
},
|
||||
"invalid_bucket_name": {
|
||||
"message": "Bucket does not exist or is not writable by the provided credentials."
|
||||
},
|
||||
|
||||
@@ -169,7 +169,6 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
|
||||
try:
|
||||
await self._camera.save_recent_clips(output_dir=file_path)
|
||||
except OSError as err:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
str(err),
|
||||
translation_domain=DOMAIN,
|
||||
@@ -191,7 +190,6 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
|
||||
try:
|
||||
await self._camera.video_to_file(filename)
|
||||
except OSError as err:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
str(err),
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.29.11",
|
||||
"dbus-fast==5.0.0",
|
||||
"dbus-fast==5.0.1",
|
||||
"habluetooth==6.1.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -183,8 +183,8 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
|
||||
try:
|
||||
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
|
||||
except BSBLANError as err:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise HomeAssistantError(
|
||||
"An error occurred while updating the BSBLAN device",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_data_error",
|
||||
) from err
|
||||
|
||||
@@ -45,8 +45,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CalDavConfigEntry) -> bo
|
||||
# on some other unexpected server response.
|
||||
_LOGGER.warning("Unexpected CalDAV server response: %s", err)
|
||||
return False
|
||||
except requests.Timeout as err:
|
||||
raise ConfigEntryNotReady("Timeout connecting to CalDAV server") from err
|
||||
except requests.ConnectionError as err:
|
||||
raise ConfigEntryNotReady("Connection error from CalDAV server") from err
|
||||
except DAVError as err:
|
||||
|
||||
@@ -38,7 +38,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import CalDavConfigEntry
|
||||
from .api import async_get_calendars
|
||||
from .const import TIMEOUT
|
||||
from .coordinator import CalDavUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -92,12 +91,7 @@ async def async_setup_platform(
|
||||
days = config[CONF_DAYS]
|
||||
|
||||
client = caldav.DAVClient(
|
||||
url,
|
||||
None,
|
||||
username,
|
||||
password,
|
||||
ssl_verify_cert=config[CONF_VERIFY_SSL],
|
||||
timeout=TIMEOUT,
|
||||
url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL]
|
||||
)
|
||||
|
||||
calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT)
|
||||
@@ -237,7 +231,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.coordinator.calendar.add_event, **item_data),
|
||||
)
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
|
||||
@callback
|
||||
|
||||
@@ -138,7 +138,7 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
)
|
||||
# refreshing async otherwise it would take too much time
|
||||
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
@@ -150,7 +150,7 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
)
|
||||
except NotFoundError as err:
|
||||
raise HomeAssistantError(f"Could not find To-do item {uid}") from err
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
|
||||
vtodo = todo.icalendar_component # type: ignore[attr-defined]
|
||||
vtodo["SUMMARY"] = item.summary or ""
|
||||
@@ -174,7 +174,7 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
)
|
||||
# refreshing async otherwise it would take too much time
|
||||
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
@@ -188,14 +188,14 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
items = await asyncio.gather(*tasks)
|
||||
except NotFoundError as err:
|
||||
raise HomeAssistantError("Could not find To-do item") from err
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
|
||||
|
||||
# Run serially as some CalDAV servers do not support concurrent modifications
|
||||
for item in items:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(item.delete)
|
||||
except (requests.ConnectionError, requests.Timeout, DAVError) as err:
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV delete error: {err}") from err
|
||||
# refreshing async otherwise it would take too much time
|
||||
self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
|
||||
|
||||
@@ -94,7 +94,7 @@ rules:
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
|
||||
@@ -65,7 +65,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except aiocomelit_exceptions.CannotAuthenticate as err:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise InvalidAuth(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_authenticate",
|
||||
|
||||
@@ -353,7 +353,6 @@ class EcovacsVacuum(
|
||||
if self._capability.clean.action.area is None:
|
||||
info = self._device.device_info
|
||||
name = info.get("nick", info["name"])
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="vacuum_send_command_area_not_supported",
|
||||
|
||||
@@ -31,7 +31,6 @@ class EkeyEvent(EventEntity):
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_event_types = ["event happened"]
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
"""The Electrolux integration."""
|
||||
|
||||
from asyncio import CancelledError
|
||||
from collections.abc import Awaitable, Callable
|
||||
import logging
|
||||
|
||||
from electrolux_group_developer_sdk.auth.token_manager import TokenManager
|
||||
from electrolux_group_developer_sdk.client.appliance_client import ApplianceClient
|
||||
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
|
||||
ApplianceData,
|
||||
)
|
||||
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
|
||||
BadCredentialsException,
|
||||
)
|
||||
from electrolux_group_developer_sdk.client.client_exception import (
|
||||
ApplianceClientException,
|
||||
)
|
||||
from electrolux_group_developer_sdk.client.failed_connection_exception import (
|
||||
FailedConnectionException,
|
||||
)
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import CONF_REFRESH_TOKEN, DOMAIN, NEW_APPLIANCE_SIGNAL, USER_AGENT
|
||||
from .coordinator import (
|
||||
ElectroluxConfigEntry,
|
||||
ElectroluxData,
|
||||
ElectroluxDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ElectroluxConfigEntry) -> bool:
|
||||
"""Set up Electrolux integration entry."""
|
||||
|
||||
token_manager = create_token_manager(hass, entry)
|
||||
client = ApplianceClient(
|
||||
token_manager=token_manager, external_user_agent=USER_AGENT
|
||||
)
|
||||
|
||||
try:
|
||||
await client.test_connection()
|
||||
except BadCredentialsException as e:
|
||||
raise ConfigEntryAuthFailed("Bad credentials detected.") from e
|
||||
except FailedConnectionException as e:
|
||||
raise ConfigEntryNotReady("Connection with client failed.") from e
|
||||
|
||||
try:
|
||||
appliances = await fetch_appliance_data(client)
|
||||
except ApplianceClientException as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
coordinators: dict[str, ElectroluxDataUpdateCoordinator] = {}
|
||||
on_livestream_opening_callback_list: list[Callable[[], Awaitable[None]]] = []
|
||||
|
||||
async def check_for_new_devices_callback() -> None:
|
||||
"""Trigger _check_for_new_devices asynchronously."""
|
||||
await _check_for_new_devices(
|
||||
hass, entry, client, on_livestream_opening_callback_list
|
||||
)
|
||||
|
||||
on_livestream_opening_callback_list.append(check_for_new_devices_callback)
|
||||
|
||||
for appliance in appliances:
|
||||
appliance_id = appliance.appliance.applianceId
|
||||
coordinator = ElectroluxDataUpdateCoordinator(
|
||||
hass, entry, client=client, appliance_id=appliance_id
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Subscribe this coordinator to its appliance events
|
||||
coordinator.add_client_listener()
|
||||
|
||||
coordinators[appliance_id] = coordinator
|
||||
# Device state is refreshed whenever the SSE connection opens.
|
||||
on_livestream_opening_callback_list.append(coordinator.async_refresh)
|
||||
|
||||
sse_task = entry.async_create_background_task(
|
||||
hass,
|
||||
client.start_event_stream(on_livestream_opening_callback_list),
|
||||
"electrolux event listener",
|
||||
)
|
||||
|
||||
entry.runtime_data = ElectroluxData(
|
||||
client=client,
|
||||
appliances=appliances,
|
||||
coordinators=coordinators,
|
||||
sse_task=sse_task,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ElectroluxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Remove SSE listeners
|
||||
coordinators = entry.runtime_data.coordinators
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.remove_client_listeners()
|
||||
|
||||
# Cancel SSE task
|
||||
sse_task = entry.runtime_data.sse_task
|
||||
sse_task.cancel()
|
||||
try:
|
||||
await sse_task
|
||||
except CancelledError:
|
||||
_LOGGER.info("SSE stream cancelled for entry %s", entry.entry_id)
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
def create_token_manager(
|
||||
hass: HomeAssistant,
|
||||
entry: ElectroluxConfigEntry,
|
||||
) -> TokenManager:
|
||||
"""Create a token manager for the Electrolux integration."""
|
||||
|
||||
def save_tokens(new_access: str, new_refresh: str, new_api_key: str) -> None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_API_KEY: new_api_key,
|
||||
CONF_ACCESS_TOKEN: new_access,
|
||||
CONF_REFRESH_TOKEN: new_refresh,
|
||||
},
|
||||
)
|
||||
|
||||
api_key = entry.data.get(CONF_API_KEY)
|
||||
refresh_token = entry.data.get(CONF_REFRESH_TOKEN)
|
||||
access_token = entry.data.get(CONF_ACCESS_TOKEN)
|
||||
|
||||
if access_token and refresh_token and api_key:
|
||||
return TokenManager(access_token, refresh_token, api_key, save_tokens)
|
||||
raise ConfigEntryAuthFailed("Missing access token, refresh token or API key")
|
||||
|
||||
|
||||
async def _check_for_new_devices(
|
||||
hass: HomeAssistant,
|
||||
entry: ElectroluxConfigEntry,
|
||||
client: ApplianceClient,
|
||||
on_livestream_opening_callback_list: list[Callable[[], Awaitable[None]]],
|
||||
) -> None:
|
||||
"""Fetch appliances from API and trigger discovery for any new ones."""
|
||||
_LOGGER.info("Checking for new devices")
|
||||
|
||||
coordinators = entry.runtime_data.coordinators
|
||||
appliances = await fetch_appliance_data(client)
|
||||
entry.runtime_data.appliances = appliances
|
||||
|
||||
existing_ids = set(coordinators.keys())
|
||||
|
||||
for appliance in appliances:
|
||||
appliance_id = appliance.appliance.applianceId
|
||||
# Detect NEW appliances
|
||||
if appliance_id not in existing_ids:
|
||||
# Create coordinator for appliance
|
||||
coordinator = ElectroluxDataUpdateCoordinator(
|
||||
hass, entry, client=client, appliance_id=appliance_id
|
||||
)
|
||||
|
||||
await coordinator.async_refresh()
|
||||
|
||||
coordinator.add_client_listener()
|
||||
coordinators[appliance_id] = coordinator
|
||||
on_livestream_opening_callback_list.append(coordinator.async_refresh)
|
||||
|
||||
# Notify all platforms
|
||||
async_dispatcher_send(
|
||||
hass, f"{NEW_APPLIANCE_SIGNAL}_{entry.entry_id}", appliance
|
||||
)
|
||||
|
||||
# Detect MISSING appliances
|
||||
discovered_ids = {appliance.appliance.applianceId for appliance in appliances}
|
||||
missing_ids = existing_ids - discovered_ids
|
||||
device_registry = dr.async_get(hass)
|
||||
for missing_id in missing_ids:
|
||||
_LOGGER.warning("Appliance %s no longer found, removing", missing_id)
|
||||
|
||||
# Remove coordinator
|
||||
coordinator = coordinators.pop(missing_id)
|
||||
coordinator.remove_client_listeners()
|
||||
on_livestream_opening_callback_list.remove(coordinator.async_refresh)
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, missing_id)}
|
||||
)
|
||||
|
||||
if device_entry:
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
|
||||
async def fetch_appliance_data(client: ApplianceClient) -> list[ApplianceData]:
|
||||
"""Helper method to retrieve all the appliances data from the Electrolux APIs."""
|
||||
try:
|
||||
appliances = await client.get_appliance_data()
|
||||
except ApplianceClientException as e:
|
||||
_LOGGER.warning("Failed to get appliances: %s", e)
|
||||
raise
|
||||
|
||||
# Filter out appliances where details or state is None
|
||||
return [
|
||||
appliance
|
||||
for appliance in appliances
|
||||
if appliance.details is not None and appliance.state is not None
|
||||
]
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Config flow for Electrolux integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from electrolux_group_developer_sdk.auth.invalid_credentials_exception import (
|
||||
InvalidCredentialsException,
|
||||
)
|
||||
from electrolux_group_developer_sdk.auth.token_manager import TokenManager
|
||||
from electrolux_group_developer_sdk.client.appliance_client import ApplianceClient
|
||||
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
|
||||
BadCredentialsException,
|
||||
)
|
||||
from electrolux_group_developer_sdk.client.failed_connection_exception import (
|
||||
FailedConnectionException,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY
|
||||
|
||||
from .const import CONF_REFRESH_TOKEN, DOMAIN, USER_AGENT
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ElectroluxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for the Electrolux integration."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step of the config flow."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
token_manager: TokenManager
|
||||
email: str
|
||||
try:
|
||||
token_manager = await _authenticate_user(user_input)
|
||||
client = ApplianceClient(
|
||||
token_manager=token_manager, external_user_agent=USER_AGENT
|
||||
)
|
||||
email = (await client.get_user_email()).email
|
||||
except InvalidCredentialsException, BadCredentialsException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except FailedConnectionException:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(token_manager.get_user_id())
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=f"Electrolux for {email}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self._show_form(step_id="user", errors=errors)
|
||||
|
||||
def _show_form(self, step_id: str, errors: dict[str, str]) -> ConfigFlowResult:
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_ACCESS_TOKEN): str,
|
||||
vol.Required(CONF_REFRESH_TOKEN): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"portal_link": "https://developer.electrolux.one/generateToken"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _authenticate_user(user_input: Mapping[str, Any]) -> TokenManager:
|
||||
token_manager = TokenManager(
|
||||
access_token=user_input[CONF_ACCESS_TOKEN],
|
||||
refresh_token=user_input[CONF_REFRESH_TOKEN],
|
||||
api_key=user_input[CONF_API_KEY],
|
||||
)
|
||||
|
||||
token_manager.ensure_credentials()
|
||||
|
||||
appliance_client = ApplianceClient(
|
||||
token_manager=token_manager, external_user_agent=USER_AGENT
|
||||
)
|
||||
|
||||
# Test a connection in the config flow
|
||||
await appliance_client.test_connection()
|
||||
|
||||
return token_manager
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Constants for Electrolux integration."""
|
||||
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
|
||||
DOMAIN = "electrolux"
|
||||
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
|
||||
NEW_APPLIANCE_SIGNAL = "electrolux_new_appliance"
|
||||
|
||||
USER_AGENT = f"HomeAssistant/{HA_VERSION}"
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Electrolux coordinator class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Task
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from electrolux_group_developer_sdk.client.appliance_client import (
|
||||
ApplianceClient,
|
||||
apply_sse_update,
|
||||
)
|
||||
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
|
||||
ApplianceData,
|
||||
)
|
||||
from electrolux_group_developer_sdk.client.client_exception import (
|
||||
ApplianceClientException,
|
||||
)
|
||||
from electrolux_group_developer_sdk.client.dto.appliance_state import ApplianceState
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(kw_only=True, slots=True)
|
||||
class ElectroluxData:
|
||||
"""Electrolux data type."""
|
||||
|
||||
client: ApplianceClient
|
||||
appliances: list[ApplianceData]
|
||||
coordinators: dict[str, ElectroluxDataUpdateCoordinator]
|
||||
sse_task: Task
|
||||
|
||||
|
||||
type ElectroluxConfigEntry = ConfigEntry[ElectroluxData]
|
||||
|
||||
|
||||
class ElectroluxDataUpdateCoordinator(DataUpdateCoordinator[ApplianceState]):
|
||||
"""Class for fetching appliance data from the API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ElectroluxConfigEntry,
|
||||
client: ApplianceClient,
|
||||
appliance_id: str,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.client = client
|
||||
self._appliance_id = appliance_id
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}_{config_entry.entry_id}_{appliance_id}",
|
||||
update_interval=None,
|
||||
always_update=False,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> ApplianceState:
|
||||
"""Return the current appliance state (SSE keeps it updated)."""
|
||||
try:
|
||||
appliance_state = await self.client.get_appliance_state(self._appliance_id)
|
||||
except ValueError as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
except ApplianceClientException as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
else:
|
||||
return appliance_state
|
||||
|
||||
def add_client_listener(self) -> None:
|
||||
"""Register an SSE listener to the appliance client for appliance state updates."""
|
||||
self.client.add_listener(self._appliance_id, self.callback_handle_event)
|
||||
|
||||
def remove_client_listeners(self) -> None:
|
||||
"""Remove all SSE listeners."""
|
||||
self.client.remove_all_listeners_by_appliance_id(self._appliance_id)
|
||||
|
||||
def callback_handle_event(self, event: dict) -> None:
|
||||
"""Handle an incoming SSE event. Event will look like: {"userId": "...", "applianceId": "...", "property": "timeToEnd", "value": 720}."""
|
||||
|
||||
current_state = self.data
|
||||
if not current_state:
|
||||
return
|
||||
|
||||
updated_state = apply_sse_update(
|
||||
current_state,
|
||||
event,
|
||||
)
|
||||
|
||||
self.async_set_updated_data(updated_state)
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Base entity for Electrolux integration."""
|
||||
|
||||
from abc import abstractmethod
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
|
||||
ApplianceData,
|
||||
)
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ElectroluxDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ElectroluxBaseEntity[T: ApplianceData](
|
||||
CoordinatorEntity[ElectroluxDataUpdateCoordinator]
|
||||
):
|
||||
"""Base class for Electrolux entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
appliance_data: T,
|
||||
coordinator: ElectroluxDataUpdateCoordinator,
|
||||
unique_id_suffix: str,
|
||||
) -> None:
|
||||
"""Initialize the base device."""
|
||||
super().__init__(coordinator)
|
||||
appliance_name = appliance_data.appliance.applianceName
|
||||
appliance_id = appliance_data.appliance.applianceId
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert appliance_data.details
|
||||
assert appliance_data.state
|
||||
|
||||
appliance_info = appliance_data.details.applianceInfo
|
||||
|
||||
self._appliance_data = appliance_data
|
||||
self._attr_unique_id = f"{appliance_id}_{unique_id_suffix}"
|
||||
self._appliance_id = appliance_id
|
||||
self._appliance_capabilities = appliance_data.details.capabilities
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, appliance_id)},
|
||||
name=appliance_name,
|
||||
manufacturer=appliance_info.brand,
|
||||
model=appliance_info.model,
|
||||
serial_number=appliance_info.serialNumber,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to HA."""
|
||||
await super().async_added_to_hass()
|
||||
self._handle_coordinator_update()
|
||||
|
||||
@abstractmethod
|
||||
def _update_attr_state(self) -> bool:
|
||||
"""Update entity-specific attributes. Returns True if any attributes were changed, otherwise False."""
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""When the coordinator updates."""
|
||||
appliance_state = self.coordinator.data
|
||||
if not appliance_state:
|
||||
_LOGGER.warning("Appliance %s not found in update", self._appliance_id)
|
||||
return
|
||||
|
||||
# Update state
|
||||
self._appliance_data.update_state(appliance_state)
|
||||
state_changed = self._update_attr_state()
|
||||
|
||||
if state_changed:
|
||||
self.async_write_ha_state()
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Contains entity helper methods."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
|
||||
ApplianceData,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NEW_APPLIANCE_SIGNAL
|
||||
from .coordinator import ElectroluxConfigEntry, ElectroluxDataUpdateCoordinator
|
||||
from .entity import ElectroluxBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entities_helper(
|
||||
hass: HomeAssistant,
|
||||
entry: ElectroluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
build_entities_fn: Callable[
|
||||
[ApplianceData, dict[str, ElectroluxDataUpdateCoordinator]],
|
||||
list[ElectroluxBaseEntity],
|
||||
],
|
||||
) -> None:
|
||||
"""Provide async_setup_entry helper."""
|
||||
|
||||
appliances: list[ApplianceData] = entry.runtime_data.appliances
|
||||
coordinators = entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ElectroluxBaseEntity] = []
|
||||
|
||||
for appliance_data in appliances:
|
||||
entities.extend(build_entities_fn(appliance_data, coordinators))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
# Listen for new/removed appliances
|
||||
async def _new_appliance(appliance_data: ApplianceData):
|
||||
new_entities = build_entities_fn(appliance_data, coordinators)
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{NEW_APPLIANCE_SIGNAL}_{entry.entry_id}", _new_appliance
|
||||
)
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"appliance_state": {
|
||||
"default": "mdi:information-outline"
|
||||
},
|
||||
"food_probe_state": {
|
||||
"default": "mdi:thermometer-probe"
|
||||
},
|
||||
"food_probe_temperature": {
|
||||
"default": "mdi:thermometer-probe"
|
||||
},
|
||||
"remote_control": {
|
||||
"default": "mdi:remote"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "electrolux",
|
||||
"name": "Electrolux",
|
||||
"codeowners": ["@electrolux-oss"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/electrolux",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["electrolux-group-developer-sdk==0.5.0"]
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No actions are implemented currently.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
Polling is only performed on infrequent events (when the livestream of events is opened, in order to sync),
|
||||
otherwise the integration works via push
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No actions are implemented currently.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -1,290 +0,0 @@
|
||||
"""Sensor entity for Electrolux Integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
|
||||
ApplianceData,
|
||||
)
|
||||
from electrolux_group_developer_sdk.client.appliances.cr_appliance import CRAppliance
|
||||
from electrolux_group_developer_sdk.client.appliances.ov_appliance import OVAppliance
|
||||
from electrolux_group_developer_sdk.feature_constants import (
|
||||
APPLIANCE_STATE,
|
||||
DISPLAY_FOOD_PROBE_TEMPERATURE_C,
|
||||
DISPLAY_FOOD_PROBE_TEMPERATURE_F,
|
||||
DISPLAY_TEMPERATURE_C,
|
||||
DISPLAY_TEMPERATURE_F,
|
||||
FOOD_PROBE_STATE,
|
||||
REMOTE_CONTROL,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from .coordinator import ElectroluxConfigEntry, ElectroluxDataUpdateCoordinator
|
||||
from .entity import ElectroluxBaseEntity
|
||||
from .entity_helper import async_setup_entities_helper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ELECTROLUX_TO_HA_TEMPERATURE_UNIT = {
|
||||
"CELSIUS": UnitOfTemperature.CELSIUS,
|
||||
"FAHRENHEIT": UnitOfTemperature.FAHRENHEIT,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ElectroluxSensorDescription(SensorEntityDescription):
|
||||
"""Custom sensor description for Electrolux sensors."""
|
||||
|
||||
value_fn: Callable[..., StateType]
|
||||
exists_fn: Callable[[ApplianceData], bool] = lambda *args: True
|
||||
feature_name: str | None = None
|
||||
known_values: set[str] | None = None
|
||||
|
||||
|
||||
OVEN_ELECTROLUX_SENSORS: tuple[ElectroluxSensorDescription, ...] = (
|
||||
ElectroluxSensorDescription(
|
||||
key="appliance_state",
|
||||
translation_key="appliance_state",
|
||||
value_fn=lambda appliance: appliance.get_current_appliance_state(),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
feature_name=APPLIANCE_STATE,
|
||||
exists_fn=lambda appliance: appliance.is_feature_supported(APPLIANCE_STATE),
|
||||
known_values={
|
||||
"alarm",
|
||||
"delayed_start",
|
||||
"end_of_cycle",
|
||||
"idle",
|
||||
"off",
|
||||
"paused",
|
||||
"ready_to_start",
|
||||
"running",
|
||||
},
|
||||
),
|
||||
ElectroluxSensorDescription(
|
||||
key="food_probe_state",
|
||||
translation_key="food_probe_state",
|
||||
value_fn=lambda appliance: appliance.get_current_food_probe_insertion_state(),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
feature_name=FOOD_PROBE_STATE,
|
||||
exists_fn=lambda appliance: appliance.is_feature_supported(FOOD_PROBE_STATE),
|
||||
known_values={
|
||||
"inserted",
|
||||
"not_inserted",
|
||||
},
|
||||
),
|
||||
ElectroluxSensorDescription(
|
||||
key="remote_control",
|
||||
translation_key="remote_control",
|
||||
value_fn=lambda appliance: appliance.get_current_remote_control(),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
feature_name=REMOTE_CONTROL,
|
||||
exists_fn=lambda appliance: appliance.is_feature_supported(REMOTE_CONTROL),
|
||||
known_values={
|
||||
"disabled",
|
||||
"enabled",
|
||||
"not_safety_relevant_enabled",
|
||||
"temporary_locked",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
OVEN_TEMPERATURE_ELECTROLUX_SENSORS: tuple[ElectroluxSensorDescription, ...] = (
|
||||
ElectroluxSensorDescription(
|
||||
key="food_probe_temperature",
|
||||
translation_key="food_probe_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda appliance, temp_unit=None: (
|
||||
appliance.get_current_display_food_probe_temperature_f()
|
||||
if temp_unit == UnitOfTemperature.FAHRENHEIT
|
||||
else appliance.get_current_display_food_probe_temperature_c()
|
||||
),
|
||||
exists_fn=lambda appliance: appliance.is_feature_supported(
|
||||
[DISPLAY_FOOD_PROBE_TEMPERATURE_F, DISPLAY_FOOD_PROBE_TEMPERATURE_C]
|
||||
),
|
||||
),
|
||||
ElectroluxSensorDescription(
|
||||
key="display_temperature",
|
||||
translation_key="display_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda appliance, temp_unit=None: (
|
||||
appliance.get_current_display_temperature_f()
|
||||
if temp_unit == UnitOfTemperature.FAHRENHEIT
|
||||
else appliance.get_current_display_temperature_c()
|
||||
),
|
||||
exists_fn=lambda appliance: appliance.is_feature_supported(
|
||||
[DISPLAY_TEMPERATURE_C, DISPLAY_TEMPERATURE_F]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_entities_for_appliance(
|
||||
appliance_data: ApplianceData,
|
||||
coordinators: dict[str, ElectroluxDataUpdateCoordinator],
|
||||
) -> list[ElectroluxBaseEntity]:
|
||||
"""Return all entities for a single appliance."""
|
||||
appliance = appliance_data.appliance
|
||||
coordinator = coordinators[appliance.applianceId]
|
||||
entities: list[ElectroluxBaseEntity] = []
|
||||
|
||||
if isinstance(appliance_data, OVAppliance):
|
||||
entities.extend(
|
||||
ElectroluxSensor(appliance_data, coordinator, description)
|
||||
for description in OVEN_ELECTROLUX_SENSORS
|
||||
if description.exists_fn(appliance_data)
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
ElectroluxTemperatureSensor(appliance_data, coordinator, description)
|
||||
for description in OVEN_TEMPERATURE_ELECTROLUX_SENSORS
|
||||
if description.exists_fn(appliance_data)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ElectroluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set sensor for Electrolux Integration."""
|
||||
await async_setup_entities_helper(
|
||||
hass, entry, async_add_entities, build_entities_for_appliance
|
||||
)
|
||||
|
||||
|
||||
class ElectroluxSensor(ElectroluxBaseEntity[ApplianceData], SensorEntity):
|
||||
"""Representation of a generic sensor for Electrolux appliances."""
|
||||
|
||||
entity_description: ElectroluxSensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
appliance_data: ApplianceData,
|
||||
coordinator: ElectroluxDataUpdateCoordinator,
|
||||
description: ElectroluxSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(appliance_data, coordinator, description.key)
|
||||
|
||||
if (
|
||||
description.feature_name is not None
|
||||
and description.known_values is not None
|
||||
):
|
||||
options = appliance_data.get_feature_state_string_options(
|
||||
description.feature_name
|
||||
)
|
||||
snake_case_options = [
|
||||
snake_case_option
|
||||
for option in options
|
||||
if (snake_case_option := _convert_to_snake_case(option))
|
||||
in description.known_values
|
||||
]
|
||||
|
||||
if len(snake_case_options) > 0:
|
||||
self._attr_options = snake_case_options
|
||||
|
||||
self.entity_description = description
|
||||
|
||||
def _update_attr_state(self) -> bool:
|
||||
new_value = self._get_value()
|
||||
if isinstance(new_value, str):
|
||||
new_value = _convert_to_snake_case(new_value)
|
||||
|
||||
if self.entity_description.known_values:
|
||||
new_value = _map_to_known_value(
|
||||
self.entity_description.known_values,
|
||||
self.entity_description.key,
|
||||
new_value,
|
||||
)
|
||||
|
||||
if self._attr_native_value != new_value:
|
||||
self._attr_native_value = new_value
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_value(self) -> StateType:
|
||||
return self.entity_description.value_fn(self._appliance_data)
|
||||
|
||||
|
||||
class ElectroluxTemperatureSensor(ElectroluxSensor):
|
||||
"""Representation of a temperature sensor for Electrolux appliances."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
appliance_data: ApplianceData,
|
||||
coordinator: ElectroluxDataUpdateCoordinator,
|
||||
description: ElectroluxSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._appliance = cast(OVAppliance | CRAppliance, appliance_data)
|
||||
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
super().__init__(appliance_data, coordinator, description)
|
||||
|
||||
def _get_value(self) -> StateType:
|
||||
temp_unit = self._get_temperature_unit()
|
||||
temp_value: float | None = cast(
|
||||
float | None,
|
||||
self.entity_description.value_fn(self._appliance_data, temp_unit=temp_unit),
|
||||
)
|
||||
if temp_value is None:
|
||||
return None
|
||||
return TemperatureConverter.convert(
|
||||
temp_value, temp_unit, UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
def _get_temperature_unit(self) -> UnitOfTemperature:
|
||||
temp_unit = self._appliance.get_current_temperature_unit()
|
||||
|
||||
if temp_unit is not None:
|
||||
temp_unit = temp_unit.upper()
|
||||
|
||||
return ELECTROLUX_TO_HA_TEMPERATURE_UNIT.get(
|
||||
temp_unit, UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
|
||||
def _convert_to_snake_case(x: str) -> str:
|
||||
"""Converts a string to snake case."""
|
||||
lower_case = x.lower()
|
||||
return "".join([_convert_char_to_snake_case(char) for char in lower_case])
|
||||
|
||||
|
||||
def _convert_char_to_snake_case(char: str) -> str:
|
||||
if char.isspace():
|
||||
return "_"
|
||||
return char
|
||||
|
||||
|
||||
def _map_to_known_value(
|
||||
known_values: set[str], entity_key: str, value: str
|
||||
) -> str | None:
|
||||
"""Return provided value if it is known, otherwise log warn message and return None."""
|
||||
if value not in known_values:
|
||||
_LOGGER.warning(
|
||||
"An unknown value %s was reported for a sensor of the Electrolux integration. "
|
||||
"Please report it for the integration, and include the following information: "
|
||||
'entity key="%s", reported value="%s"',
|
||||
value,
|
||||
entity_key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
return value
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This Electrolux account is already configured."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Unable to connect to the Electrolux API. Please check credentials and try again.",
|
||||
"invalid_auth": "Authentication failed. Please check your credentials."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"access_token": "[%key:common::config_flow::data::access_token%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"refresh_token": "Refresh token"
|
||||
},
|
||||
"data_description": {
|
||||
"access_token": "The access token from Electrolux Group for Developer.",
|
||||
"api_key": "Your Electrolux Group for Developer API key.",
|
||||
"refresh_token": "The refresh token used to renew your access token."
|
||||
},
|
||||
"description": "Please go to the [developer portal]({portal_link}) to generate new access and refresh tokens, then paste them below.",
|
||||
"title": "Configure your Electrolux Group account"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"appliance_state": {
|
||||
"name": "Appliance state",
|
||||
"state": {
|
||||
"alarm": "Alarm",
|
||||
"delayed_start": "Delayed start",
|
||||
"end_of_cycle": "Cycle ended",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"ready_to_start": "Ready to start",
|
||||
"running": "Running"
|
||||
}
|
||||
},
|
||||
"display_temperature": {
|
||||
"name": "Current temperature"
|
||||
},
|
||||
"food_probe_state": {
|
||||
"name": "Food probe state",
|
||||
"state": {
|
||||
"inserted": "Inserted",
|
||||
"not_inserted": "Not inserted"
|
||||
}
|
||||
},
|
||||
"food_probe_temperature": {
|
||||
"name": "Food probe temperature"
|
||||
},
|
||||
"remote_control": {
|
||||
"name": "Remote control",
|
||||
"state": {
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"enabled": "[%key:common::state::enabled%]",
|
||||
"not_safety_relevant_enabled": "Not safety relevant enabled",
|
||||
"temporary_locked": "Temporarily locked"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ rules:
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
|
||||
@@ -364,7 +364,6 @@ class ESPHomeManager:
|
||||
response_dict = {"response": response}
|
||||
|
||||
except TemplateError as ex:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise HomeAssistantError(
|
||||
f"Error rendering response template: {ex}"
|
||||
) from ex
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.0.4",
|
||||
"aioesphomeapi==45.0.2",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
|
||||
@@ -509,12 +509,14 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription(
|
||||
icon="mdi:battery",
|
||||
scope=FitbitScope.DEVICE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
has_entity_name=True,
|
||||
)
|
||||
FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription(
|
||||
key="devices/battery_level",
|
||||
translation_key="battery_level",
|
||||
scope=FitbitScope.DEVICE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
has_entity_name=True,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
)
|
||||
@@ -652,7 +654,6 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
|
||||
entity_description: FitbitSensorEntityDescription
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -712,7 +713,6 @@ class FitbitBatteryLevelSensor(
|
||||
"""Implementation of a Fitbit battery level sensor."""
|
||||
|
||||
entity_description: FitbitSensorEntityDescription
|
||||
_attr_has_entity_name = True
|
||||
_attr_attribution = ATTRIBUTION
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -63,7 +63,7 @@ rules:
|
||||
comment: |
|
||||
This integration does not have many entities. All of them are fundamental.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
|
||||
@@ -61,20 +61,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
|
||||
except FRITZ_AUTH_EXCEPTIONS as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except FRITZ_EXCEPTIONS as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_connecting",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in avm_wrapper.connection.services
|
||||
and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_upnp_disabled",
|
||||
)
|
||||
raise ConfigEntryAuthFailed("Missing UPnP configuration")
|
||||
|
||||
await avm_wrapper.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -185,18 +185,12 @@
|
||||
"config_entry_not_found": {
|
||||
"message": "Failed to perform action \"{service}\". Config entry for target not found"
|
||||
},
|
||||
"error_connecting": {
|
||||
"message": "Error connecting to the FRITZ!Box: {error}"
|
||||
},
|
||||
"error_parse_device_info": {
|
||||
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
|
||||
},
|
||||
"error_refresh_hosts_info": {
|
||||
"message": "Error refreshing hosts info"
|
||||
},
|
||||
"error_upnp_disabled": {
|
||||
"message": "UPnP is disabled on the FRITZ!Box. Please enable UPnP to use this integration."
|
||||
},
|
||||
"service_dial_failed": {
|
||||
"message": "Failed to dial, check if the click to dial service of the FRITZ!Box is activated"
|
||||
},
|
||||
@@ -206,6 +200,9 @@
|
||||
"service_parameter_unknown": {
|
||||
"message": "Action or parameter unknown"
|
||||
},
|
||||
"unable_to_connect": {
|
||||
"message": "Unable to establish a connection"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Error while updating the data: {error}"
|
||||
}
|
||||
|
||||
@@ -438,7 +438,6 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity):
|
||||
self._attr_is_on = turn_on
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-missing-has-entity-name
|
||||
class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
"""Defines a FRITZ!Box Tools PortForward switch."""
|
||||
|
||||
@@ -605,7 +604,6 @@ class FritzBoxProfileSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-missing-has-entity-name
|
||||
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
"""Defines a FRITZ!Box Tools Wifi switch."""
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
try:
|
||||
await self._client.async_upload_backup(wrapped_open_stream, backup)
|
||||
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupAgentError(f"Failed to upload backup: {err}") from err
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
@@ -108,7 +107,6 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
try:
|
||||
return await self._client.async_list_backups()
|
||||
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupAgentError(f"Failed to list backups: {err}") from err
|
||||
|
||||
async def async_get_backup(
|
||||
@@ -121,7 +119,6 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
for backup in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
return backup
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
async def async_download_backup(
|
||||
@@ -142,9 +139,7 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
stream = await self._client.async_download(file_id)
|
||||
return ChunkAsyncStreamIterator(stream)
|
||||
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupAgentError(f"Failed to download backup: {err}") from err
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
async def async_delete_backup(
|
||||
@@ -165,7 +160,5 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
_LOGGER.debug("Deleted backup_id: %s", backup_id)
|
||||
return
|
||||
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupAgentError(f"Failed to delete backup: {err}") from err
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
@@ -84,7 +84,6 @@ async def _async_handle_upload(call: ServiceCall) -> ServiceResponse:
|
||||
|
||||
scopes = config_entry.data["token"]["scope"].split(" ")
|
||||
if UPLOAD_SCOPE not in scopes:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_upload_permission",
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["guntamatic==1.8.0"]
|
||||
"requirements": ["guntamatic==1.6.0"]
|
||||
}
|
||||
|
||||
@@ -109,7 +109,6 @@ class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity):
|
||||
try:
|
||||
await self._send_message(message)
|
||||
except NotAuthorizedError as e:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_forbidden",
|
||||
@@ -119,7 +118,6 @@ class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity):
|
||||
},
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_not_found",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Actions for the Habitica integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC, datetime, time
|
||||
from datetime import UTC, date, datetime, time
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID, uuid4
|
||||
@@ -740,7 +740,7 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
|
||||
reminders.extend(
|
||||
Reminders(
|
||||
id=uuid4(),
|
||||
time=datetime.combine(dt_util.now().date(), r, tzinfo=UTC),
|
||||
time=datetime.combine(date.today(), r, tzinfo=UTC), # noqa: DTZ011
|
||||
)
|
||||
for r in add_reminders
|
||||
if r not in existing_reminder_times
|
||||
@@ -806,10 +806,10 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
|
||||
data["daysOfMonth"] = [start_date.day]
|
||||
data["weeksOfMonth"] = []
|
||||
|
||||
if (interval := call.data.get(ATTR_INTERVAL)) is not None:
|
||||
if interval := call.data.get(ATTR_INTERVAL):
|
||||
data["everyX"] = interval
|
||||
|
||||
if (streak := call.data.get(ATTR_STREAK)) is not None:
|
||||
if streak := call.data.get(ATTR_STREAK):
|
||||
data["streak"] = streak
|
||||
|
||||
try:
|
||||
|
||||
@@ -272,6 +272,7 @@ class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_ON,
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
@@ -52,7 +52,9 @@ reload_config_entry:
|
||||
target:
|
||||
fields:
|
||||
entry_id:
|
||||
advanced: true
|
||||
required: false
|
||||
example: 8955375327824e14ba89e4b29cc3ec9a
|
||||
selector:
|
||||
config_entry:
|
||||
|
||||
|
||||
@@ -223,10 +223,10 @@
|
||||
"name": "Reload all Home Assistant configuration"
|
||||
},
|
||||
"reload_config_entry": {
|
||||
"description": "Reloads any explicitly provided config entry ID and any config entries referenced by entities or devices in the target. If both are provided, the union of those config entries is reloaded.",
|
||||
"description": "Reloads the specified config entry.",
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"description": "Optional configuration entry ID to reload.",
|
||||
"description": "The configuration entry ID of the entry to be reloaded.",
|
||||
"name": "Config entry ID"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -59,7 +59,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
|
||||
@@ -180,24 +180,27 @@ async def async_setup_entry(
|
||||
class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP cloud connection sensor."""
|
||||
|
||||
_attr_translation_key = "cloud_connection"
|
||||
|
||||
def __init__(self, hap: HomematicipHAP) -> None:
|
||||
"""Initialize the cloud connection sensor."""
|
||||
super().__init__(hap, hap.home, feature_id="cloud_connection")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name cloud connection entity."""
|
||||
|
||||
name = "Cloud Connection"
|
||||
# Add a prefix to the name if the homematic ip home has a name.
|
||||
return name if not self._home.name else f"{self._home.name} {name}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device specific attributes."""
|
||||
# Merges into the existing HAP device registered in __init__.py.
|
||||
# Name must match __init__.py logic for has_entity_name to work.
|
||||
label = self._home.label or ""
|
||||
# Adds a sensor to the existing HAP device
|
||||
return DeviceInfo(
|
||||
identifiers={
|
||||
# Serial numbers of Homematic IP device
|
||||
(DOMAIN, self._home.id)
|
||||
},
|
||||
name=label,
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -576,7 +579,6 @@ class HomematicipPluggableMainsFailureSurveillanceSensor(
|
||||
class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP security zone sensor group."""
|
||||
|
||||
_attr_has_entity_name = False
|
||||
_attr_device_class = BinarySensorDeviceClass.SAFETY
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -74,7 +74,6 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
|
||||
basically enabled in the hmip app.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = False
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
)
|
||||
|
||||
@@ -320,7 +320,6 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
|
||||
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
|
||||
"""Representation of the HomematicIP cover shutter group."""
|
||||
|
||||
_attr_has_entity_name = False
|
||||
_attr_device_class = CoverDeviceClass.SHUTTER
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None:
|
||||
|
||||
@@ -74,7 +74,6 @@ GROUP_ATTRIBUTES = {
|
||||
class HomematicipGenericEntity(Entity):
|
||||
"""Representation of the HomematicIP generic entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
@@ -113,14 +112,6 @@ class HomematicipGenericEntity(Entity):
|
||||
# Marker showing that the HmIP device hase been removed.
|
||||
self.hmip_device_removed = False
|
||||
|
||||
# Compute entity name based on has_entity_name mode.
|
||||
if not self._attr_has_entity_name:
|
||||
# Legacy mode (groups, special entities): compose the full name
|
||||
# including device/group label and home prefix.
|
||||
self._attr_name = self._compute_legacy_name()
|
||||
else:
|
||||
self._setup_entity_name()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
"""Return device specific attributes."""
|
||||
@@ -129,14 +120,6 @@ class HomematicipGenericEntity(Entity):
|
||||
device_id = str(self._device.id)
|
||||
home_id = str(self._device.homeId)
|
||||
|
||||
# Include the home name in the device name so that the
|
||||
# previous "{home} {device}" naming is preserved after
|
||||
# switching to has_entity_name=True.
|
||||
device_name = self._device.label
|
||||
home_name = getattr(self._home, "name", None)
|
||||
if device_name and home_name:
|
||||
device_name = f"{home_name} {device_name}"
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={
|
||||
# Serial numbers of Homematic IP device
|
||||
@@ -144,7 +127,7 @@ class HomematicipGenericEntity(Entity):
|
||||
},
|
||||
manufacturer=self._device.oem,
|
||||
model=self._device.modelType,
|
||||
name=device_name,
|
||||
name=self._device.label,
|
||||
sw_version=self._device.firmwareVersion,
|
||||
# Link to the homematic ip access point.
|
||||
via_device=(DOMAIN, home_id),
|
||||
@@ -217,93 +200,38 @@ class HomematicipGenericEntity(Entity):
|
||||
self.async_remove(force_remove=True), eager_start=False
|
||||
)
|
||||
|
||||
def _compute_legacy_name(self) -> str:
|
||||
"""Compute the full legacy name for entities without has_entity_name.
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the generic entity."""
|
||||
|
||||
Used by group entities and other special cases where has_entity_name
|
||||
is False. Includes device/group label, post suffix, and home prefix.
|
||||
"""
|
||||
name = self._device.label or ""
|
||||
if self._post:
|
||||
name = f"{name} {self._post}" if name else self._post
|
||||
name = ""
|
||||
# Try to get a label from a channel.
|
||||
functional_channels = getattr(self._device, "functionalChannels", None)
|
||||
if functional_channels and self.functional_channel:
|
||||
if self._is_multi_channel:
|
||||
label = getattr(self.functional_channel, "label", None)
|
||||
if label:
|
||||
name = str(label)
|
||||
elif len(functional_channels) > 1:
|
||||
label = getattr(functional_channels[1], "label", None)
|
||||
if label:
|
||||
name = str(label)
|
||||
|
||||
# Use device label, if name is not defined by channel label.
|
||||
if not name:
|
||||
name = self._device.label or ""
|
||||
if self._post:
|
||||
name = f"{name} {self._post}"
|
||||
elif self._is_multi_channel:
|
||||
name = f"{name} Channel{self.get_channel_index()}"
|
||||
|
||||
# Add a prefix to the name if the homematic ip home has a name.
|
||||
home_name = getattr(self._home, "name", None)
|
||||
if name and home_name:
|
||||
name = f"{home_name} {name}"
|
||||
|
||||
return name
|
||||
|
||||
def _setup_entity_name(self) -> None:
|
||||
"""Set up entity naming for has_entity_name mode.
|
||||
|
||||
With has_entity_name=True, HA composes the full friendly name as
|
||||
"{device_name} {entity_name}". This method sets the appropriate
|
||||
naming attributes.
|
||||
|
||||
For multi-channel entities, channel labels provide _attr_name (dynamic).
|
||||
For entities with _post, _attr_name is derived from the post suffix,
|
||||
with the first letter capitalized for display consistency.
|
||||
For primary entities, HA uses device_class as the name.
|
||||
"""
|
||||
# Multi-channel entities: use channel label as entity name.
|
||||
if self._is_multi_channel and self.functional_channel:
|
||||
label = getattr(self.functional_channel, "label", None)
|
||||
if label:
|
||||
label_str = str(label)
|
||||
device_label = self._device.label or ""
|
||||
# Strip device name prefix from channel label to avoid
|
||||
# duplication when HA composes "{device_name} {entity_name}".
|
||||
# E.g., device "Licht Flur" + channel "Licht Flur 5" -> "5".
|
||||
if device_label and label_str.startswith(device_label):
|
||||
stripped = label_str[len(device_label) :].strip()
|
||||
if stripped:
|
||||
self._attr_name = stripped
|
||||
# Otherwise channel label equals device label (modulo
|
||||
# whitespace); leave _attr_name unset so HA composes just
|
||||
# the device name without duplicating it.
|
||||
return
|
||||
self._attr_name = label_str
|
||||
return
|
||||
# Fallback: use post suffix or generic channel name.
|
||||
if self._post:
|
||||
self._attr_name = self._post[0].upper() + self._post[1:]
|
||||
else:
|
||||
self._attr_name = f"Channel{self.get_channel_index()}"
|
||||
return
|
||||
|
||||
# Entities with a post suffix: use it as the entity name,
|
||||
# capitalizing the first letter for display consistency.
|
||||
if self._post:
|
||||
self._attr_name = self._post[0].upper() + self._post[1:]
|
||||
return
|
||||
|
||||
# Non-multi-channel entities on devices with multiple channels:
|
||||
# use the first functional channel's label as name context.
|
||||
# This preserves names like "Treppe CH" for single-function entities
|
||||
# on multi-channel devices (e.g., HmIP-BSL switch channel).
|
||||
functional_channels = getattr(self._device, "functionalChannels", None)
|
||||
if functional_channels and len(functional_channels) > 1:
|
||||
ch1 = (
|
||||
functional_channels.get(1)
|
||||
if isinstance(functional_channels, dict)
|
||||
else functional_channels[1]
|
||||
)
|
||||
label = getattr(ch1, "label", None) if ch1 else None
|
||||
if label:
|
||||
label_str = str(label)
|
||||
device_label = self._device.label or ""
|
||||
# Strip device name prefix to avoid duplication.
|
||||
if device_label and label_str.startswith(device_label):
|
||||
stripped = label_str[len(device_label) :].strip()
|
||||
if stripped:
|
||||
self._attr_name = stripped
|
||||
# Otherwise channel label equals device label (modulo
|
||||
# whitespace); leave _attr_name unset.
|
||||
return
|
||||
self._attr_name = label_str
|
||||
return
|
||||
|
||||
# Primary entity on device: leave unset so HA derives name from
|
||||
# device_class or translation_key.
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
|
||||
@@ -82,6 +82,7 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
|
||||
super().__init__(
|
||||
hap,
|
||||
device,
|
||||
post=description.key,
|
||||
channel=channel,
|
||||
is_multi_channel=False,
|
||||
feature_id="doorbell",
|
||||
|
||||
@@ -1070,7 +1070,9 @@ class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity):
|
||||
description: HmipSmokeDetectorSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the smoke detector sensor."""
|
||||
super().__init__(hap, device, feature_id="smoke_detector_sensor")
|
||||
super().__init__(
|
||||
hap, device, post=description.key, feature_id="smoke_detector_sensor"
|
||||
)
|
||||
self.entity_description = description
|
||||
self._sensor_unique_id = f"{device.id}_{description.key}"
|
||||
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"cloud_connection": {
|
||||
"name": "Cloud connection"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"optical_signal_light": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -142,8 +142,6 @@ class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):
|
||||
class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity):
|
||||
"""Representation of the HomematicIP switching group."""
|
||||
|
||||
_attr_has_entity_name = False
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device, post: str = "Group") -> None:
|
||||
"""Initialize switching group."""
|
||||
device.modelType = f"HmIP-{post}"
|
||||
|
||||
@@ -74,6 +74,11 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity):
|
||||
"""Initialize the weather sensor."""
|
||||
super().__init__(hap, device, feature_id="weather")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the sensor."""
|
||||
return self._device.label
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float:
|
||||
"""Return the platform temperature."""
|
||||
@@ -113,7 +118,6 @@ class HomematicipWeatherSensorPro(HomematicipWeatherSensor):
|
||||
class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity):
|
||||
"""Representation of the HomematicIP home weather."""
|
||||
|
||||
_attr_has_entity_name = False
|
||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
|
||||
_attr_attribution = "Powered by Homematic IP"
|
||||
|
||||
@@ -50,14 +50,12 @@ def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
|
||||
translation_key="auth_failed",
|
||||
) from error
|
||||
except HomevoltConnectionError as error:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
except HomevoltError as error:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_error",
|
||||
|
||||
@@ -44,7 +44,6 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
|
||||
data = await self.api.combined()
|
||||
|
||||
except RequestError as ex:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise UpdateFailed(
|
||||
ex, translation_domain=DOMAIN, translation_key="communication_error"
|
||||
) from ex
|
||||
@@ -61,7 +60,6 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
|
||||
self.config_entry.entry_id
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise UpdateFailed(
|
||||
ex, translation_domain=DOMAIN, translation_key="api_disabled"
|
||||
) from ex
|
||||
|
||||
@@ -16,7 +16,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_COMMAND,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
@@ -40,6 +39,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_COMMAND = "command"
|
||||
|
||||
EVENT_BUTTON_PRESS = "homeworks_button_press"
|
||||
EVENT_BUTTON_RELEASE = "homeworks_button_release"
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
|
||||
@@ -94,7 +94,7 @@ rules:
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
|
||||
@@ -62,7 +62,7 @@ rules:
|
||||
comment: >
|
||||
The device class is a service. When removed, entities are removed as well.
|
||||
diagnostics: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
|
||||
@@ -73,9 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) ->
|
||||
except InvalidHeaterList as exc:
|
||||
raise NoHeaters from exc
|
||||
except InvalidGateway as exc:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="incorrect_credentials"
|
||||
) from exc
|
||||
raise ConfigEntryAuthFailed("Incorrect credentials") from exc
|
||||
except ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
raise NotFound from exc
|
||||
|
||||
@@ -15,12 +15,10 @@ from incomfortclient import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -79,20 +77,12 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
|
||||
try:
|
||||
for heater in self.incomfort_data.heaters:
|
||||
await heater.update()
|
||||
except TimeoutError as exc:
|
||||
raise UpdateFailed("Timeout error") from exc
|
||||
except ClientResponseError as exc:
|
||||
if exc.status == 401:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="incorrect_credentials"
|
||||
) from exc
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_with_error_message",
|
||||
translation_placeholders={"error": exc.message},
|
||||
) from exc
|
||||
raise ConfigEntryError("Incorrect credentials") from exc
|
||||
raise UpdateFailed(exc.message) from exc
|
||||
except InvalidHeaterList as exc:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_with_error_message",
|
||||
translation_placeholders={"error": exc.message},
|
||||
) from exc
|
||||
raise UpdateFailed(exc.message) from exc
|
||||
return self.incomfort_data
|
||||
|
||||
@@ -131,7 +131,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"incorrect_credentials": { "message": "Incorrect credentials." },
|
||||
"no_heaters": {
|
||||
"message": "[%key:component::incomfort::config::error::no_heaters%]"
|
||||
},
|
||||
@@ -143,9 +142,6 @@
|
||||
},
|
||||
"unknown": {
|
||||
"message": "[%key:component::incomfort::config::error::unknown%]"
|
||||
},
|
||||
"update_failed_with_error_message": {
|
||||
"message": "Update failed, got {error}."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -10,7 +10,6 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_MODEL
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_GENERATION, CONF_SERIAL_NUMBER, DEFAULT_PORT, DOMAIN
|
||||
|
||||
@@ -22,12 +21,6 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self._discovered_host: str | None = None
|
||||
self._discovered_device_data: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -90,55 +83,6 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery — probe the device to confirm it is an Indevolt device."""
|
||||
host = discovery_info.ip
|
||||
|
||||
try:
|
||||
device_data = await self._async_get_device_data(host)
|
||||
except OSError, ClientError, KeyError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER])
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: host}, reload_on_update=True
|
||||
)
|
||||
|
||||
self.context["title_placeholders"] = {"model": device_data[CONF_MODEL]}
|
||||
self._discovered_host = host
|
||||
self._discovered_device_data = device_data
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm DHCP discovery by user."""
|
||||
assert self._discovered_host is not None
|
||||
assert self._discovered_device_data is not None
|
||||
|
||||
# Attempt to setup from user input
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=f"INDEVOLT {self._discovered_device_data[CONF_MODEL]}",
|
||||
data={
|
||||
CONF_HOST: self._discovered_host,
|
||||
**self._discovered_device_data,
|
||||
},
|
||||
)
|
||||
|
||||
# Retrieve user confirmation
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={
|
||||
CONF_HOST: self._discovered_host,
|
||||
CONF_MODEL: self._discovered_device_data[CONF_MODEL],
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_validate_input(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[dict[str, str], dict[str, Any] | None]:
|
||||
|
||||
@@ -72,11 +72,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
config_data = await self.api.get_config()
|
||||
except (ClientError, OSError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err
|
||||
|
||||
# Cache device information
|
||||
device_data = config_data.get("device", {})
|
||||
@@ -91,11 +87,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
return await self.api.fetch_data(sensor_keys)
|
||||
except (ClientError, OSError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Device update failed: {err}") from err
|
||||
|
||||
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
|
||||
"""Push/write data values to given key on the device."""
|
||||
|
||||
@@ -3,12 +3,6 @@
|
||||
"name": "Indevolt",
|
||||
"codeowners": ["@xirt"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{ "macaddress": "1C784B*" },
|
||||
{ "macaddress": "34EAE7*" },
|
||||
{ "macaddress": "7C3E82*" },
|
||||
{ "registered_devices": true }
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/indevolt",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -40,8 +40,12 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration does not support network discovery
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration does not support network discovery
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to add {model} ({host}) to Home Assistant?",
|
||||
"title": "Discovered Indevolt {model}"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
@@ -373,9 +369,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_ready": {
|
||||
"message": "Device config retrieval failed: {error}"
|
||||
},
|
||||
"energy_mode_change_unavailable_outdoor_portable": {
|
||||
"message": "Energy mode cannot be changed when the device is in outdoor/portable mode"
|
||||
},
|
||||
@@ -403,9 +396,6 @@
|
||||
"soc_below_minimum": {
|
||||
"message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Device update failed: {error}"
|
||||
},
|
||||
"write_error": {
|
||||
"message": "Cannot update value for {name}"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from infrared_protocols.commands import Command as InfraredCommand
|
||||
import voluptuous as vol
|
||||
@@ -105,49 +104,7 @@ def async_subscribe_receiver(
|
||||
return entity.async_subscribe_received_signal(signal_callback)
|
||||
|
||||
|
||||
class InfraredConsumerEntity(Entity):
|
||||
"""Base class for entities that track the availability of an infrared entity."""
|
||||
|
||||
@callback
|
||||
def _async_track_availability(self, infrared_entity_id: str) -> CALLBACK_TYPE:
|
||||
"""Track the availability of an infrared entity.
|
||||
|
||||
Sets initial availability and subscribes to state changes.
|
||||
Returns an unsubscribe callback.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
new_state = event.data["new_state"]
|
||||
ir_available = (
|
||||
new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
if ir_available != self.available:
|
||||
_LOGGER.info(
|
||||
"Infrared entity %s used by %s is %s",
|
||||
infrared_entity_id,
|
||||
self.entity_id,
|
||||
"available" if ir_available else "unavailable",
|
||||
)
|
||||
self._async_infrared_availability_changed(ir_available)
|
||||
|
||||
ir_state = self.hass.states.get(infrared_entity_id)
|
||||
self._attr_available = (
|
||||
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
return async_track_state_change_event(
|
||||
self.hass, [infrared_entity_id], state_changed
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_infrared_availability_changed(self, available: bool) -> None:
|
||||
"""Update availability. Override to react to changes."""
|
||||
self._attr_available = available
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class InfraredEmitterConsumerEntity(InfraredConsumerEntity):
|
||||
class InfraredEmitterConsumerEntity(Entity):
|
||||
"""Base entity for integrations that send commands via an infrared emitter.
|
||||
|
||||
Tracks the availability of the underlying infrared emitter entity.
|
||||
@@ -159,8 +116,19 @@ class InfraredEmitterConsumerEntity(InfraredConsumerEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to infrared entity state changes."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.async_on_remove(
|
||||
self._async_track_availability(self._infrared_emitter_entity_id)
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self._infrared_emitter_entity_id],
|
||||
self._async_ir_state_changed,
|
||||
)
|
||||
)
|
||||
|
||||
# Set initial availability based on current infrared entity state
|
||||
ir_state = self.hass.states.get(self._infrared_emitter_entity_id)
|
||||
self._attr_available = (
|
||||
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
async def _send_command(self, command: InfraredCommand) -> None:
|
||||
@@ -169,8 +137,24 @@ class InfraredEmitterConsumerEntity(InfraredConsumerEntity):
|
||||
self.hass, self._infrared_emitter_entity_id, command, context=self._context
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_ir_state_changed(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle infrared entity state changes."""
|
||||
new_state = event.data["new_state"]
|
||||
ir_available = new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
if ir_available != self.available:
|
||||
_LOGGER.info(
|
||||
"Infrared entity %s used by %s is %s",
|
||||
self._infrared_emitter_entity_id,
|
||||
self.entity_id,
|
||||
"available" if ir_available else "unavailable",
|
||||
)
|
||||
|
||||
class InfraredReceiverConsumerEntity(InfraredConsumerEntity):
|
||||
self._attr_available = ir_available
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class InfraredReceiverConsumerEntity(Entity):
|
||||
"""Base entity for integrations that consume signals from an infrared receiver.
|
||||
|
||||
Tracks the availability of the underlying infrared receiver entity and
|
||||
@@ -184,20 +168,24 @@ class InfraredReceiverConsumerEntity(InfraredConsumerEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to infrared entity state changes and receiver signals."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.async_on_remove(
|
||||
self._async_track_availability(self._infrared_receiver_entity_id)
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self._infrared_receiver_entity_id],
|
||||
self._async_ir_state_changed,
|
||||
)
|
||||
)
|
||||
|
||||
# Set initial availability based on current infrared entity state
|
||||
ir_state = self.hass.states.get(self._infrared_receiver_entity_id)
|
||||
self._attr_available = (
|
||||
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
self._async_update_receiver_subscription()
|
||||
self.async_on_remove(self._async_unsubscribe_receiver)
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _async_infrared_availability_changed(self, available: bool) -> None:
|
||||
"""Update availability and manage receiver subscription."""
|
||||
super()._async_infrared_availability_changed(available)
|
||||
self._async_update_receiver_subscription()
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def _handle_signal(self, signal: InfraredReceivedSignal) -> None:
|
||||
@@ -225,3 +213,20 @@ class InfraredReceiverConsumerEntity(InfraredConsumerEntity):
|
||||
self._remove_signal_subscription = async_subscribe_receiver(
|
||||
self.hass, self._infrared_receiver_entity_id, self._handle_signal
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_ir_state_changed(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle infrared entity state changes."""
|
||||
new_state = event.data["new_state"]
|
||||
ir_available = new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
if ir_available != self.available:
|
||||
_LOGGER.info(
|
||||
"Infrared entity %s used by %s is %s",
|
||||
self._infrared_receiver_entity_id,
|
||||
self.entity_id,
|
||||
"available" if ir_available else "unavailable",
|
||||
)
|
||||
self._attr_available = ir_available
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._async_update_receiver_subscription()
|
||||
|
||||
@@ -60,7 +60,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: done
|
||||
|
||||
@@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) -
|
||||
try:
|
||||
await hass.async_add_executor_job(train_schedule.query, start, destination)
|
||||
except Exception as e:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_timeout",
|
||||
|
||||
@@ -13,7 +13,6 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
CONF_VARIABLES,
|
||||
CONF_VERIFY_SSL,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
@@ -32,9 +31,9 @@ from .const import (
|
||||
CONF_IGNORE_STRING,
|
||||
CONF_NETWORK,
|
||||
CONF_SENSOR_STRING,
|
||||
CONF_TLS_VER,
|
||||
DEFAULT_IGNORE_STRING,
|
||||
DEFAULT_SENSOR_STRING,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
ISY_CONF_FIRMWARE,
|
||||
ISY_CONF_MODEL,
|
||||
@@ -63,16 +62,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
|
||||
"""Migrate old config entries."""
|
||||
if entry.version == 1 and entry.minor_version == 1:
|
||||
new_data = {key: value for key, value in entry.data.items() if key != "tls"}
|
||||
new_data.setdefault(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
|
||||
hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
|
||||
"""Set up the ISY 994 integration."""
|
||||
isy_config = entry.data
|
||||
@@ -82,7 +71,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
|
||||
user = isy_config[CONF_USERNAME]
|
||||
password = isy_config[CONF_PASSWORD]
|
||||
host = urlparse(isy_config[CONF_HOST])
|
||||
verify_ssl = isy_config[CONF_VERIFY_SSL]
|
||||
|
||||
# Optional
|
||||
tls_version = isy_config.get(CONF_TLS_VER)
|
||||
ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING)
|
||||
sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING)
|
||||
|
||||
@@ -95,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
|
||||
elif host.scheme == SCHEME_HTTPS:
|
||||
https = True
|
||||
port = host.port or 443
|
||||
session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl)
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
else:
|
||||
_LOGGER.error("The ISY/IoX host value in configuration is invalid")
|
||||
return False
|
||||
@@ -107,7 +98,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
|
||||
username=user,
|
||||
password=password,
|
||||
use_https=https,
|
||||
verify_ssl=verify_ssl,
|
||||
tls_ver=tls_version,
|
||||
webroot=host.path,
|
||||
websession=session,
|
||||
use_websocket=True,
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import CookieJar
|
||||
from pyisy import ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError
|
||||
from pyisy.configuration import Configuration
|
||||
@@ -19,13 +18,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -41,12 +34,13 @@ from .const import (
|
||||
CONF_IGNORE_STRING,
|
||||
CONF_RESTORE_LIGHT_STATE,
|
||||
CONF_SENSOR_STRING,
|
||||
CONF_TLS_VER,
|
||||
CONF_VAR_SENSOR_STRING,
|
||||
DEFAULT_IGNORE_STRING,
|
||||
DEFAULT_RESTORE_LIGHT_STATE,
|
||||
DEFAULT_SENSOR_STRING,
|
||||
DEFAULT_TLS_VERSION,
|
||||
DEFAULT_VAR_SENSOR_STRING,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
HTTP_PORT,
|
||||
HTTPS_PORT,
|
||||
@@ -69,7 +63,7 @@ def _data_schema(schema_input: dict[str, str]) -> vol.Schema:
|
||||
vol.Required(CONF_HOST, default=schema_input.get(CONF_HOST, "")): str,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
|
||||
vol.Optional(CONF_TLS_VER, default=DEFAULT_TLS_VERSION): vol.In([1.1, 1.2]),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -83,7 +77,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
user = data[CONF_USERNAME]
|
||||
password = data[CONF_PASSWORD]
|
||||
host = urlparse(data[CONF_HOST])
|
||||
verify_ssl = data[CONF_VERIFY_SSL]
|
||||
tls_version = data.get(CONF_TLS_VER)
|
||||
|
||||
if host.scheme == SCHEME_HTTP:
|
||||
https = False
|
||||
@@ -94,7 +88,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
elif host.scheme == SCHEME_HTTPS:
|
||||
https = True
|
||||
port = host.port or HTTPS_PORT
|
||||
session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl)
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
else:
|
||||
_LOGGER.error("The ISY/IoX host value in configuration is invalid")
|
||||
raise InvalidHost
|
||||
@@ -106,7 +100,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
user,
|
||||
password,
|
||||
use_https=https,
|
||||
verify_ssl=verify_ssl,
|
||||
tls_ver=tls_version,
|
||||
webroot=host.path,
|
||||
websession=session,
|
||||
)
|
||||
@@ -117,10 +111,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
except ISYInvalidAuthError as error:
|
||||
raise InvalidAuth from error
|
||||
except ISYConnectionError as error:
|
||||
# pyisy chains the underlying aiohttp error via __cause__; ClientSSLError
|
||||
# covers both protocol mismatch and certificate verification failures.
|
||||
if isinstance(error.__cause__, aiohttp.ClientSSLError):
|
||||
raise SslError from error
|
||||
raise CannotConnect from error
|
||||
|
||||
try:
|
||||
@@ -141,7 +131,6 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Universal Devices ISY/IoX."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the ISY/IoX config flow."""
|
||||
@@ -167,8 +156,6 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except SslError:
|
||||
errors["base"] = "ssl_error"
|
||||
except InvalidHost:
|
||||
errors["base"] = "invalid_host"
|
||||
except InvalidAuth:
|
||||
@@ -304,8 +291,6 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await validate_input(self.hass, new_data)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except SslError:
|
||||
errors["base"] = "ssl_error"
|
||||
except InvalidAuth:
|
||||
errors[CONF_PASSWORD] = "invalid_auth"
|
||||
else:
|
||||
@@ -383,9 +368,5 @@ class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class SslError(HomeAssistantError):
|
||||
"""Error to indicate a TLS/SSL handshake failure."""
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
@@ -69,12 +69,13 @@ CONF_NETWORK = "network"
|
||||
CONF_IGNORE_STRING = "ignore_string"
|
||||
CONF_SENSOR_STRING = "sensor_string"
|
||||
CONF_VAR_SENSOR_STRING = "variable_sensor_string"
|
||||
CONF_TLS_VER = "tls"
|
||||
CONF_RESTORE_LIGHT_STATE = "restore_light_state"
|
||||
|
||||
DEFAULT_IGNORE_STRING = "{IGNORE ME}"
|
||||
DEFAULT_SENSOR_STRING = "sensor"
|
||||
DEFAULT_RESTORE_LIGHT_STATE = False
|
||||
DEFAULT_VERIFY_SSL = False
|
||||
DEFAULT_TLS_VERSION = 1.1
|
||||
DEFAULT_PROGRAM_STRING = "HA."
|
||||
DEFAULT_VAR_SENSOR_STRING = "HA."
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyisy"],
|
||||
"requirements": ["pyisy==3.6.1"],
|
||||
"requirements": ["pyisy==3.5.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1",
|
||||
|
||||
@@ -7,7 +7,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CODE,
|
||||
CONF_COMMAND,
|
||||
CONF_NAME,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
@@ -39,6 +38,8 @@ SERVICE_DELETE_ZWAVE_LOCK_USER_CODE = "delete_zwave_lock_user_code"
|
||||
CONF_PARAMETER = "parameter"
|
||||
CONF_PARAMETERS = "parameters"
|
||||
CONF_USER_NUM = "user_num"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_CODE = "code"
|
||||
CONF_VALUE = "value"
|
||||
CONF_INIT = "init"
|
||||
CONF_ISY = "isy"
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_host": "The host entry was not in full URL format, e.g., {sample_ip}",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"ssl_error": "TLS handshake failed. The controller may require a newer TLS version, or SSL verification may be failing due to a self-signed certificate.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
@@ -26,11 +25,8 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::url%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"verify_ssl": "Verify the controller's TLS certificate. Leave disabled for ISY-994/eisy/Polisy controllers using their default self-signed certificate."
|
||||
"tls": "The TLS version of the ISY controller.",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "The host entry must be in full URL format, e.g., {sample_ip}",
|
||||
"title": "Connect to your ISY"
|
||||
|
||||
@@ -556,8 +556,9 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
vol.Required(
|
||||
CONF_KNX_ROUTE_BACK, default=_route_back
|
||||
): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_KNX_LOCAL_IP): _IP_SELECTOR,
|
||||
}
|
||||
if self.show_advanced_options:
|
||||
fields[vol.Optional(CONF_KNX_LOCAL_IP)] = _IP_SELECTOR
|
||||
|
||||
if not self._found_tunnels and not errors.get("base"):
|
||||
errors["base"] = "no_tunnel_discovered"
|
||||
@@ -889,8 +890,10 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
): selector.BooleanSelector(),
|
||||
vol.Required(CONF_KNX_MCAST_GRP, default=_multicast_group): _IP_SELECTOR,
|
||||
vol.Required(CONF_KNX_MCAST_PORT, default=_multicast_port): _PORT_SELECTOR,
|
||||
vol.Optional(CONF_KNX_LOCAL_IP): _IP_SELECTOR,
|
||||
}
|
||||
if self.show_advanced_options:
|
||||
# Optional with default doesn't work properly in flow UI
|
||||
fields[vol.Optional(CONF_KNX_LOCAL_IP)] = _IP_SELECTOR
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="routing", data_schema=vol.Schema(fields), errors=errors
|
||||
|
||||
@@ -115,7 +115,6 @@ async def async_attach_trigger(
|
||||
try:
|
||||
trigger_config = TRIGGER_TRIGGER_SCHEMA(trigger_config)
|
||||
except vol.Invalid as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise InvalidDeviceAutomationConfig(f"{err}") from err
|
||||
|
||||
return await trigger.async_attach_trigger(
|
||||
|
||||
@@ -66,7 +66,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
|
||||
@@ -105,7 +105,6 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
except ThinQAPIException as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
exc.message, translation_domain=DOMAIN, translation_key=exc.code
|
||||
) from exc
|
||||
|
||||
@@ -44,10 +44,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
|
||||
try:
|
||||
devices = await client.get_devices()
|
||||
except LiebherrAuthenticationError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed("Invalid API key") from err
|
||||
except LiebherrConnectionError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
|
||||
|
||||
# Create a coordinator for each device (may be empty if no devices)
|
||||
|
||||
@@ -58,10 +58,8 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
|
||||
try:
|
||||
await self.client.get_device(self.device_id)
|
||||
except LiebherrAuthenticationError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed("Invalid API key") from err
|
||||
except LiebherrConnectionError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryNotReady(
|
||||
f"Failed to connect to device {self.device_id}: {err}"
|
||||
) from err
|
||||
@@ -71,15 +69,12 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
|
||||
try:
|
||||
return await self.client.get_device_state(self.device_id)
|
||||
except LiebherrAuthenticationError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed("API key is no longer valid") from err
|
||||
except LiebherrTimeoutError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed(
|
||||
f"Timeout communicating with device {self.device_id}"
|
||||
) from err
|
||||
except LiebherrConnectionError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with device {self.device_id}"
|
||||
) from err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user