Compare commits

..

2 Commits

Author SHA1 Message Date
Franck Nijhof 38a5fad551 Address review: skip duplicate fetch when language is English 2026-05-20 13:05:59 +00:00
Franck Nijhof 86fc954e6d Fix onboarding crash when area translations are missing 2026-05-20 12:55:07 +00:00
278 changed files with 4277 additions and 7430 deletions
@@ -18,13 +18,6 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
4. Ensure any existing review comments have been addressed.
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
## Verification:
- After the review, run parallel subagents for each finding to double check it.
- Spawn up to a maximum of 10 parallel subagents at a time.
- Gather the results from the subagents and summarize them in the final review comments.
## IMPORTANT:
- Just review. DO NOT make any changes
- Be constructive and specific in your comments
+1
View File
@@ -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
-1
View File
@@ -128,7 +128,6 @@
"standard-aifc",
"standard-telnetlib",
"ulid-transform",
"unidiff",
"url-normalize",
"xmltodict"
],
+1 -1
View File
@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.05.0"
BASE_IMAGE_VERSION: "2026.04.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
@@ -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
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"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: |
+325 -287
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
3.14.5
3.14.4
+3 -3
View File
@@ -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
@@ -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."
},
-2
View File
@@ -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,
@@ -19,7 +19,7 @@
"bleak-retry-connector==4.6.0",
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.29.11",
"bluetooth-data-tools==1.28.4",
"dbus-fast==5.0.0",
"habluetooth==6.1.0"
]
@@ -183,7 +183,6 @@ 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,
+1 -5
View File
@@ -17,8 +17,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import TIMEOUT
type CalDavConfigEntry = ConfigEntry[caldav.DAVClient]
_LOGGER = logging.getLogger(__name__)
@@ -34,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CalDavConfigEntry) -> bo
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
ssl_verify_cert=entry.data[CONF_VERIFY_SSL],
timeout=TIMEOUT,
timeout=30,
)
try:
await hass.async_add_executor_job(client.principal)
@@ -45,8 +43,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:
+2 -8
View File
@@ -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
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, TIMEOUT
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -65,7 +65,6 @@ class CalDavConfigFlow(ConfigFlow, domain=DOMAIN):
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
ssl_verify_cert=user_input[CONF_VERIFY_SSL],
timeout=TIMEOUT,
)
try:
await self.hass.async_add_executor_job(client.principal)
@@ -76,9 +75,6 @@ class CalDavConfigFlow(ConfigFlow, domain=DOMAIN):
# AuthorizationError can be raised if the url is incorrect or
# on some other unexpected server response.
return "cannot_connect"
except requests.Timeout as err:
_LOGGER.warning("Timeout connecting to CalDAV server: %s", err)
return "cannot_connect"
except requests.ConnectionError as err:
_LOGGER.warning("Connection Error connecting to CalDAV server: %s", err)
return "cannot_connect"
-1
View File
@@ -3,4 +3,3 @@
from typing import Final
DOMAIN: Final = "caldav"
TIMEOUT: Final = 30
+5 -5
View File
@@ -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))
@@ -7,7 +7,6 @@ from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
PLATFORMS: list[Platform] = [
@@ -25,9 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
if not ble_device:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"address": address},
f"Could not find Casper Glow device with address {address}"
)
glow = CasperGlow(ble_device)
@@ -54,9 +54,6 @@
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the Casper Glow: {error}"
},
"device_not_found": {
"message": "Could not find Casper Glow device with address {address}"
}
}
}
+4 -2
View File
@@ -17,8 +17,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
APPLICATION_NAME,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
@@ -47,6 +45,10 @@ HA_USER_AGENT = (
)
ATTR_UID = "uid"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LATITUDE = "latitude"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LONGITUDE = "longitude"
ATTR_EMPTY_SLOTS = "empty_slots"
ATTR_FREE_EBIKES = "free_ebikes"
ATTR_TIMESTAMP = "timestamp"
@@ -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",
+1 -5
View File
@@ -60,11 +60,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
raise ConfigEntryError(f"Duco API error: {err}") from err
async def _async_update_data(self) -> DucoData:
"""Fetch node data from the Duco box."""
+4 -4
View File
@@ -95,7 +95,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
value_fn=lambda node: node.sensor.co2 if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
node_types=(NodeType.UCCO2,),
),
DucoSensorEntityDescription(
key="iaq_co2",
@@ -104,7 +104,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_co2 if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
node_types=(NodeType.UCCO2,),
),
DucoSensorEntityDescription(
key="humidity",
@@ -112,7 +112,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda node: node.sensor.rh if node.sensor else None,
node_types=(NodeType.BSRH, NodeType.UCRH, NodeType.VLVRH, NodeType.VLVCO2RH),
node_types=(NodeType.BSRH, NodeType.UCRH),
),
DucoSensorEntityDescription(
key="iaq_rh",
@@ -121,7 +121,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_rh if node.sensor else None,
node_types=(NodeType.BSRH, NodeType.UCRH, NodeType.VLVRH, NodeType.VLVCO2RH),
node_types=(NodeType.BSRH, NodeType.UCRH),
),
)
@@ -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",
@@ -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"
],
@@ -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
+2 -9
View File
@@ -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()
+3 -6
View File
@@ -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}"
}
@@ -5,11 +5,7 @@ from typing import Any
from google_air_quality_api.api import GoogleAirQualityApi
from google_air_quality_api.auth import Auth
from google_air_quality_api.exceptions import (
GoogleAirQualityApiError,
InvalidCustomLAQIConfigurationError,
)
from google_air_quality_api.mapping import AQICategoryMapping
from google_air_quality_api.exceptions import GoogleAirQualityApiError
import voluptuous as vol
from homeassistant.config_entries import (
@@ -22,7 +18,6 @@ from homeassistant.config_entries import (
)
from homeassistant.const import (
CONF_API_KEY,
CONF_COUNTRY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
@@ -31,28 +26,11 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import SectionConfig, section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
CountrySelector,
LocationSelector,
LocationSelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
from .const import (
CONF_ENABLE_CUSTOM_LAQI,
CONF_REFERRER,
CUSTOM_LAQI,
CUSTOM_LOCAL_AQI_OPTIONS,
DOMAIN,
SECTION_API_KEY_OPTIONS,
)
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
_LOGGER = logging.getLogger(__name__)
AIR_QUALITY_COVERAGE_URL = (
"https://developers.google.com/maps/documentation/air-quality/coverage"
)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
@@ -72,31 +50,10 @@ async def _validate_input(
description_placeholders: dict[str, str],
) -> bool:
try:
custom_options = user_input.get(CUSTOM_LOCAL_AQI_OPTIONS) or {}
enable_custom_laqi = custom_options.get(CONF_ENABLE_CUSTOM_LAQI)
if enable_custom_laqi:
country = custom_options.get(CONF_COUNTRY)
custom_laqi = custom_options.get(CUSTOM_LAQI)
# When custom LAQI is enabled, both country and custom_laqi must be provided
if not country or not custom_laqi:
errors[CUSTOM_LOCAL_AQI_OPTIONS] = "missing_custom_laqi_options"
return False
await api.async_get_current_conditions(
lat=user_input[CONF_LOCATION][CONF_LATITUDE],
lon=user_input[CONF_LOCATION][CONF_LONGITUDE],
region_code=country,
custom_local_aqi=custom_laqi,
)
else:
await api.async_get_current_conditions(
lat=user_input[CONF_LOCATION][CONF_LATITUDE],
lon=user_input[CONF_LOCATION][CONF_LONGITUDE],
)
except InvalidCustomLAQIConfigurationError:
errors["base"] = "mismatch_country_and_laqi"
await api.async_get_current_conditions(
lat=user_input[CONF_LOCATION][CONF_LATITUDE],
lon=user_input[CONF_LOCATION][CONF_LONGITUDE],
)
except GoogleAirQualityApiError as err:
errors["base"] = "cannot_connect"
description_placeholders["error_message"] = str(err)
@@ -122,25 +79,6 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
CONF_LONGITUDE: hass.config.longitude,
},
): LocationSelector(LocationSelectorConfig(radius=False)),
vol.Optional(CUSTOM_LOCAL_AQI_OPTIONS): section(
vol.Schema(
{
vol.Required(CONF_ENABLE_CUSTOM_LAQI, default=False): bool,
vol.Optional(
CONF_COUNTRY, default=hass.config.country
): CountrySelector(),
vol.Optional(CUSTOM_LAQI): SelectSelector(
SelectSelectorConfig(
options=sorted(
AQICategoryMapping.get_all_laq_indices()
),
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
SectionConfig(collapsed=True),
),
}
)
@@ -185,7 +123,6 @@ class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {
"api_key_url": "https://developers.google.com/maps/documentation/air-quality/get-api-key",
"air_quality_coverage_url": AIR_QUALITY_COVERAGE_URL,
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
}
if user_input is not None:
@@ -195,13 +132,10 @@ class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN):
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
session = async_get_clientsession(self.hass)
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
auth = Auth(session, user_input[CONF_API_KEY], referrer=referrer)
api = GoogleAirQualityApi(auth)
if await _validate_input(user_input, api, errors, description_placeholders):
subentry_data = dict(user_input[CONF_LOCATION])
custom_opts = user_input.get(CUSTOM_LOCAL_AQI_OPTIONS)
if custom_opts and custom_opts.get(CONF_ENABLE_CUSTOM_LAQI):
subentry_data[CUSTOM_LOCAL_AQI_OPTIONS] = custom_opts
return self.async_create_entry(
title="Google Air Quality",
data={
@@ -211,7 +145,7 @@ class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN):
subentries=[
{
"subentry_type": "location",
"data": subentry_data,
"data": user_input[CONF_LOCATION],
"title": user_input[CONF_NAME],
"unique_id": None,
},
@@ -251,9 +185,7 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {
"air_quality_coverage_url": AIR_QUALITY_COVERAGE_URL
}
description_placeholders: dict[str, str] = {}
if user_input is not None:
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
errors["base"] = "location_already_configured"
@@ -270,13 +202,9 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
description_placeholders=description_placeholders,
)
if await _validate_input(user_input, api, errors, description_placeholders):
data = dict(user_input[CONF_LOCATION])
custom_options = user_input.get(CUSTOM_LOCAL_AQI_OPTIONS)
if custom_options and custom_options.get(CONF_ENABLE_CUSTOM_LAQI):
data[CUSTOM_LOCAL_AQI_OPTIONS] = custom_options
return self.async_create_entry(
title=user_input[CONF_NAME],
data=data,
data=user_input[CONF_LOCATION],
)
else:
user_input = {}
@@ -2,9 +2,6 @@
from typing import Final
CONF_ENABLE_CUSTOM_LAQI: Final = "enable_custom_laqi"
CONF_REFERRER: Final = "referrer"
CUSTOM_LAQI: Final = "custom_laqi"
CUSTOM_LOCAL_AQI_OPTIONS: Final = "custom_local_aqi_options"
DOMAIN: Final = "google_air_quality"
DOMAIN = "google_air_quality"
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
CONF_REFERRER: Final = "referrer"
@@ -10,16 +10,11 @@ from google_air_quality_api.exceptions import GoogleAirQualityApiError
from google_air_quality_api.model import AirQualityCurrentConditionsData
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_ENABLE_CUSTOM_LAQI,
CUSTOM_LAQI,
CUSTOM_LOCAL_AQI_OPTIONS,
DOMAIN,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -54,27 +49,11 @@ class GoogleAirQualityUpdateCoordinator(
subentry = config_entry.subentries[subentry_id]
self.lat = subentry.data[CONF_LATITUDE]
self.long = subentry.data[CONF_LONGITUDE]
self.custom_local_aqi: str | None = None
self.region_code: str | None = None
options = subentry.data.get(CUSTOM_LOCAL_AQI_OPTIONS)
if isinstance(options, dict) and options.get(CONF_ENABLE_CUSTOM_LAQI):
custom_laqi = options.get(CUSTOM_LAQI)
region_code = options.get(CONF_COUNTRY)
if custom_laqi is not None and region_code is not None:
self.custom_local_aqi = custom_laqi
self.region_code = region_code
async def _async_update_data(self) -> AirQualityCurrentConditionsData:
"""Fetch air quality data for this coordinate."""
try:
return await self.client.async_get_current_conditions(
lat=self.lat,
lon=self.long,
region_code=self.region_code,
custom_local_aqi=self.custom_local_aqi,
)
return await self.client.async_get_current_conditions(self.lat, self.long)
except GoogleAirQualityApiError as ex:
_LOGGER.debug("Cannot fetch air quality data: %s", str(ex))
raise UpdateFailed(
@@ -204,6 +204,7 @@ async def async_setup_entry(
for subentry_id, subentry in entry.subentries.items():
coordinator = coordinators[subentry_id]
_LOGGER.debug("subentry.data: %s", subentry.data)
async_add_entities(
(
AirQualitySensorEntity(coordinator, description, subentry_id, subentry)
@@ -15,8 +15,6 @@
},
"error": {
"cannot_connect": "Unable to connect to the Google Air Quality API:\n\n{error_message}",
"mismatch_country_and_laqi": "This local AQI is not available for the selected country. Please select an available combination.",
"missing_custom_laqi_options": "Please provide both country and custom local AQI when custom local AQI is enabled.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
@@ -41,20 +39,6 @@
"referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})."
},
"name": "Optional API key options"
},
"custom_local_aqi_options": {
"data": {
"country": "[%key:common::config_flow::data::country%]",
"custom_laqi": "Custom local AQI",
"enable_custom_laqi": "Enable custom local AQI"
},
"data_description": {
"country": "Country of the location",
"custom_laqi": "The target air quality index",
"enable_custom_laqi": "Select to enable a custom local air quality index"
},
"description": "Country and custom local AQI must match. You can find the available combinations here: {air_quality_coverage_url}",
"name": "Custom local AQI options"
}
}
}
@@ -67,11 +51,8 @@
},
"entry_type": "Air quality location",
"error": {
"cannot_connect": "[%key:component::google_air_quality::config::error::cannot_connect%]",
"location_already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"location_name_already_configured": "Location name already configured.",
"mismatch_country_and_laqi": "[%key:component::google_air_quality::config::error::mismatch_country_and_laqi%]",
"missing_custom_laqi_options": "[%key:component::google_air_quality::config::error::missing_custom_laqi_options%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
@@ -88,22 +69,6 @@
"name": "[%key:component::google_air_quality::config::step::user::data_description::name%]"
},
"description": "Select the coordinates for which you want to create an entry.",
"sections": {
"custom_local_aqi_options": {
"data": {
"country": "[%key:common::config_flow::data::country%]",
"custom_laqi": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data::custom_laqi%]",
"enable_custom_laqi": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data::enable_custom_laqi%]"
},
"data_description": {
"country": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data_description::country%]",
"custom_laqi": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data_description::custom_laqi%]",
"enable_custom_laqi": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data_description::enable_custom_laqi%]"
},
"description": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::description%]",
"name": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::name%]"
}
},
"title": "Air quality data location"
}
}
@@ -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:
+2 -1
View File
@@ -14,7 +14,6 @@ from homeassistant.components.sensor import (
SensorEntity,
)
from homeassistant.const import (
ATTR_MODEL,
CONF_DISKS,
CONF_HOST,
CONF_NAME,
@@ -29,6 +28,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
ATTR_DEVICE = "device"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 7634
@@ -37,6 +37,7 @@ from homeassistant.components.media_player import (
RepeatMode,
async_process_play_media_url,
)
from homeassistant.components.media_source import BrowseMediaSource
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@@ -623,7 +624,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
async def _async_browse_media_source(
self, media_content_id: str | None = None
) -> media_source.BrowseMediaSource | media_source.RootBrowseMediaSource:
) -> BrowseMediaSource:
"""Browse a media source item."""
return await media_source.async_browse_media(
self.hass,
@@ -266,7 +266,6 @@ class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
value=BSH_POWER_ON,
)
except HomeConnectError as err:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="power_on",
@@ -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
@@ -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,7 +73,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) ->
except InvalidHeaterList as exc:
raise NoHeaters from exc
except InvalidGateway as exc:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed("Incorrect credentials") from exc
except ClientResponseError as exc:
if exc.status == 404:
@@ -78,15 +78,11 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
for heater in self.incomfort_data.heaters:
await heater.update()
except TimeoutError as exc:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed("Timeout error") from exc
except ClientResponseError as exc:
if exc.status == 401:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryError("Incorrect credentials") from exc
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(exc.message) from exc
except InvalidHeaterList as exc:
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(exc.message) from exc
return self.incomfort_data
@@ -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,15 +3,9 @@
"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",
"quality_scale": "silver",
"requirements": ["indevolt-api==1.8.1"]
"requirements": ["indevolt-api==1.8.0"]
}
@@ -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}"
}
+59 -54
View File
@@ -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",
+6 -15
View File
@@ -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,
+7 -26
View File
@@ -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."""
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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"
+2 -6
View File
@@ -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"
@@ -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(
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.29.11", "ld2410-ble==0.1.1"]
"requirements": ["bluetooth-data-tools==1.28.4", "ld2410-ble==0.1.1"]
}
@@ -36,5 +36,5 @@
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.29.11", "led-ble==1.1.8"]
"requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.8"]
}
@@ -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
@@ -15,7 +15,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Local file from a config entry."""
file_path: str = entry.options[CONF_FILE_PATH]
if not await hass.async_add_executor_job(check_file_path_access, file_path):
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="not_readable_path",
@@ -77,7 +77,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
await coordinator_info.async_config_entry_first_refresh()
if info_api.data is None or info_api.serial_number is None:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="missing_device_info"
)
+34 -53
View File
@@ -29,13 +29,12 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import (
DOMAIN,
LYRIC_EXCEPTIONS,
PRESET_HOLD_UNTIL,
PRESET_NO_HOLD,
@@ -361,13 +360,10 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
heat_setpoint=target_temp_low,
mode=mode,
)
except LYRIC_EXCEPTIONS as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_temperature",
) from err
finally:
await self.coordinator.async_refresh()
# pylint: disable-next=home-assistant-action-swallowed-exception
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
await self.coordinator.async_refresh()
else:
temp = kwargs.get(ATTR_TEMPERATURE)
_LOGGER.debug("Set temperature: %s", temp)
@@ -380,13 +376,9 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
await self._update_thermostat(
self.location, device, heat_setpoint=temp
)
except LYRIC_EXCEPTIONS as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_temperature",
) from err
finally:
await self.coordinator.async_refresh()
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
await self.coordinator.async_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
@@ -397,13 +389,10 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
await self._async_set_hvac_mode_tcc(hvac_mode)
case LyricThermostatType.LCC:
await self._async_set_hvac_mode_lcc(hvac_mode)
except LYRIC_EXCEPTIONS as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_hvac_mode",
) from err
finally:
await self.coordinator.async_refresh()
# pylint: disable-next=home-assistant-action-swallowed-exception
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
await self.coordinator.async_refresh()
async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode for TCC devices (e.g., Lyric round)."""
@@ -479,13 +468,10 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
await self._update_thermostat(
self.location, self.device, thermostat_setpoint_status=preset_mode
)
except LYRIC_EXCEPTIONS as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_preset_mode",
) from err
finally:
await self.coordinator.async_refresh()
# pylint: disable-next=home-assistant-action-swallowed-exception
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
await self.coordinator.async_refresh()
async def async_set_hold_time(self, time_period: str) -> None:
"""Set the time to hold until."""
@@ -497,31 +483,26 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
thermostat_setpoint_status=PRESET_HOLD_UNTIL,
next_period_time=time_period,
)
except LYRIC_EXCEPTIONS as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_hold_time",
) from err
finally:
await self.coordinator.async_refresh()
# pylint: disable-next=home-assistant-action-swallowed-exception
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
await self.coordinator.async_refresh()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
_LOGGER.debug("Set fan mode: %s", fan_mode)
if fan_mode not in LYRIC_FAN_MODES:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_fan_mode",
translation_placeholders={"fan_mode": fan_mode},
)
mode = LYRIC_FAN_MODES[fan_mode]
_LOGGER.debug("Fan mode passed to lyric: %s", mode)
try:
await self._update_fan(self.location, self.device, mode=mode)
except LYRIC_EXCEPTIONS as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_fan_mode",
) from err
finally:
await self.coordinator.async_refresh()
_LOGGER.debug("Fan mode passed to lyric: %s", LYRIC_FAN_MODES[fan_mode])
await self._update_fan(
self.location, self.device, mode=LYRIC_FAN_MODES[fan_mode]
)
# pylint: disable-next=home-assistant-action-swallowed-exception
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
except KeyError:
_LOGGER.error(
"The fan mode requested does not have a"
" corresponding mode in lyric: %s",
fan_mode,
)
await self.coordinator.async_refresh()
@@ -65,24 +65,6 @@
}
},
"exceptions": {
"failed_to_set_fan_mode": {
"message": "Failed to set fan mode"
},
"failed_to_set_hold_time": {
"message": "Failed to set hold time"
},
"failed_to_set_hvac_mode": {
"message": "Failed to set HVAC mode"
},
"failed_to_set_preset_mode": {
"message": "Failed to set preset mode"
},
"failed_to_set_temperature": {
"message": "Failed to set temperature"
},
"invalid_fan_mode": {
"message": "The fan mode {fan_mode} is not supported by this device"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
@@ -422,7 +422,6 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
},
)
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
"Invalid alarm code provided",
translation_domain=DOMAIN,
@@ -53,10 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) ->
translation_key="auth_failed",
) from error
except MastodonError as ex:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_connect",
) from ex
raise ConfigEntryNotReady("Failed to connect") from ex
assert entry.unique_id
@@ -62,9 +62,6 @@ class MastodonCoordinator(DataUpdateCoordinator[Account]):
translation_key="auth_failed",
) from error
except MastodonError as ex:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
) from ex
raise UpdateFailed(ex) from ex
return account

Some files were not shown because too many files have changed in this diff Show More