mirror of
https://github.com/home-assistant/core.git
synced 2026-05-21 00:05:16 +02:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f5d74cfbd | |||
| c188fdcc8b | |||
| a3b43fc19b | |||
| 894a68acb6 | |||
| 30bc3fc412 | |||
| 3cc0cc38ab | |||
| 296caa90c1 | |||
| bb4c211fb6 | |||
| d4fa904386 | |||
| db98f0b434 | |||
| 7341ac91ee | |||
| b2fb5df0fb | |||
| 265485a7d0 | |||
| bf1b93fb66 | |||
| be9d4bedfd | |||
| e8ac982e83 | |||
| 6c8e5a8e98 | |||
| e40a3e18db | |||
| cba05caadd | |||
| ef3bc61e2b | |||
| 3eff36eb9d | |||
| 8402a4d876 | |||
| 6159516dc0 | |||
| 8fd3dcc7b1 | |||
| b724e52408 | |||
| 1654f7b0f7 | |||
| c8b23d52ba | |||
| 75d2babe65 | |||
| 39ad57acfd | |||
| 162729a176 | |||
| 376d94e7e1 | |||
| c3223b29a4 | |||
| 073ee88a64 | |||
| ef8b4f2d7f | |||
| 0df063b420 | |||
| 79fe415d6c | |||
| 00e3a909a0 | |||
| a85fb79331 | |||
| 7f2f268fca | |||
| 4c31a1737d | |||
| abd8d85225 | |||
| 626a1a5c87 | |||
| 1b2e8ccc0f | |||
| 4da2cd465a | |||
| e3c31a3482 | |||
| a0b52e0f58 | |||
| 75dd509c7b | |||
| 6540ccd52a | |||
| a35ad41495 | |||
| cedf5a5861 | |||
| 16f4dc74bf | |||
| c5f22936e4 | |||
| aa23b3176c | |||
| a144bbab2b | |||
| 6a20b99252 | |||
| 8a12c06116 | |||
| 5dc057b36d | |||
| 6d6f14a0aa | |||
| 7bd81aeb9f | |||
| 8dc29b5411 | |||
| 1cabcf522e | |||
| ff8d244839 | |||
| d1a5b0dbd3 | |||
| 86bab0c0f6 | |||
| 7f320a5a41 | |||
| 309ce5545e | |||
| 293d7851ba | |||
| eeb9270241 | |||
| b841a26aff | |||
| 40f7a2f50f | |||
| 15b230c4e7 | |||
| 49a14112b7 | |||
| e59e631a87 | |||
| c1d0c9fb9f | |||
| ee7461ed9c | |||
| 9757f8b574 | |||
| 5ee96a3616 | |||
| 703ac31bd1 | |||
| 52bf0b0ee0 | |||
| 29db335930 | |||
| 8257107462 | |||
| c7618949da | |||
| 592154bd27 | |||
| 7919330ae0 | |||
| 6ffe1bab9a | |||
| 91705ef821 | |||
| e15797af14 | |||
| a966ce4586 | |||
| ee2de6641f | |||
| 5f85ae6f95 | |||
| 275e0b3dd1 | |||
| a9475683e1 | |||
| d4e1a7075e | |||
| 2a3d75eb2b | |||
| 9212d2300c | |||
| 6836b27ba6 | |||
| 8656f52d7a | |||
| 6a07ca93e9 | |||
| 77990c8808 | |||
| e4227ee1d4 | |||
| 87aca7416f | |||
| 1ec6619a20 | |||
| 85013282e4 | |||
| ceab93ab83 | |||
| ac636ce54f | |||
| 3287b01ed1 | |||
| 3acc7d08b3 | |||
| 16eb5dce63 | |||
| 3fee05db71 | |||
| f823ef639a | |||
| da4263b95c | |||
| 29e2184163 | |||
| 816c3ff939 |
@@ -18,6 +18,13 @@ 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
|
||||
|
||||
@@ -19,7 +19,6 @@ machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
requirements_all.txt linguist-generated=true
|
||||
requirements_test_all.txt linguist-generated=true
|
||||
requirements_test_pre_commit.txt linguist-generated=true
|
||||
script/hassfest/docker/Dockerfile linguist-generated=true
|
||||
.github/workflows/*.lock.yml linguist-generated=true
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"standard-aifc",
|
||||
"standard-telnetlib",
|
||||
"ulid-transform",
|
||||
"unidiff",
|
||||
"url-normalize",
|
||||
"xmltodict"
|
||||
],
|
||||
|
||||
@@ -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.04.0"
|
||||
BASE_IMAGE_VERSION: "2026.05.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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}"
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
@@ -1,69 +0,0 @@
|
||||
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 headRepo = context.payload.workflow_run.head_repository.full_name;
|
||||
const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
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}.`);
|
||||
}
|
||||
+102
-54
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1520b9c030f2826a211027ca57be8e2af424502aa190380c6860d9f777425eac","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1ad29b8fb97f5df4466be54051779a3188f094d7efb041a8ed55211eab33c5f5","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/
|
||||
#
|
||||
# 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), the package source does not show obvious signs of malicious behavior (Home Assistant secret/config access, download-and-execute, install-time arbitrary code, credential exfiltration, obfuscated dynamic execution), and the PR description contains the required links.
|
||||
# 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.
|
||||
#
|
||||
# Secrets used:
|
||||
# - COPILOT_GITHUB_TOKEN
|
||||
@@ -46,30 +46,30 @@
|
||||
# - ghcr.io/github/github-mcp-server:v1.0.4
|
||||
# - node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f
|
||||
|
||||
name: "Check requirements"
|
||||
name: "Check requirements (AW)"
|
||||
on:
|
||||
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
|
||||
workflow_run:
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
types:
|
||||
- completed
|
||||
workflows:
|
||||
- Check requirements (deterministic)
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ inputs.pull_request_number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
run-name: "Check requirements"
|
||||
run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs: pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
(!(github.event.workflow_run.repository.fork)))
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -92,8 +92,10 @@ 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"
|
||||
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"
|
||||
@@ -106,7 +108,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"
|
||||
GH_AW_INFO_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_INFO_EXPERIMENTAL: "false"
|
||||
GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
|
||||
GH_AW_INFO_STAGED: "false"
|
||||
@@ -183,25 +185,24 @@ 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_3604055927054880_EOF'
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_3604055927054880_EOF
|
||||
GH_AW_PROMPT_bb296919e461941b_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_3604055927054880_EOF'
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_3604055927054880_EOF
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_3604055927054880_EOF'
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -230,19 +231,18 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_3604055927054880_EOF
|
||||
GH_AW_PROMPT_bb296919e461941b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_3604055927054880_EOF'
|
||||
cat << 'GH_AW_PROMPT_bb296919e461941b_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_3604055927054880_EOF
|
||||
GH_AW_PROMPT_bb296919e461941b_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');
|
||||
@@ -261,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');
|
||||
@@ -282,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_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
|
||||
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
|
||||
}
|
||||
});
|
||||
- name: Validate prompt placeholders
|
||||
@@ -317,9 +317,12 @@ jobs:
|
||||
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: ""
|
||||
@@ -352,7 +355,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"
|
||||
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"
|
||||
@@ -374,6 +377,20 @@ 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 }}
|
||||
- if: github.event.workflow_run.conclusion == 'success'
|
||||
name: Extract PR number from artifact
|
||||
run: |-
|
||||
PR=$(python3 -c 'import json,sys;print(json.load(open("/tmp/gh-aw/deterministic/results.json"))["pr_number"])')
|
||||
echo "PR_NUMBER=${PR}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Configure Git credentials
|
||||
env:
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
@@ -433,21 +450,19 @@ 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_38b421c33811530a_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_38b421c33811530a_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_c09f5151c817ddfc_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ env.PR_NUMBER }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_c09f5151c817ddfc_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: ${{ inputs.pull_request_number }}. Supports reply_to_id for discussion threading."
|
||||
"add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ env.PR_NUMBER }}. Supports reply_to_id for discussion threading."
|
||||
},
|
||||
"repo_params": {},
|
||||
"dynamic_tools": []
|
||||
@@ -633,7 +648,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_73bab7f9eb3c0972_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -643,7 +658,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"
|
||||
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
|
||||
},
|
||||
"guard-policies": {
|
||||
"allow-only": {
|
||||
@@ -677,7 +692,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_73bab7f9eb3c0972_EOF
|
||||
GH_AW_MCP_CONFIG_d12799b4d7ffe5c2_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -876,7 +891,7 @@ jobs:
|
||||
if [ ! -f /tmp/gh-aw/agent_output.json ]; then
|
||||
echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
|
||||
fi
|
||||
- if: always()
|
||||
- if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
name: Verify agent produced an add_comment safe-output
|
||||
run: |-
|
||||
OUTPUT=/tmp/gh-aw/agent_output.json
|
||||
@@ -953,7 +968,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"
|
||||
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"
|
||||
@@ -977,7 +992,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"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
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"
|
||||
@@ -993,7 +1008,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"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
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 }}
|
||||
@@ -1010,7 +1025,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"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1024,7 +1039,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"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1038,7 +1053,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"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
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"
|
||||
@@ -1092,7 +1107,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"
|
||||
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"
|
||||
@@ -1160,8 +1175,8 @@ jobs:
|
||||
if: always() && steps.detection_guard.outputs.run_detection == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
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), the package source does not show obvious signs of malicious behavior (Home Assistant secret/config access, download-and-execute, install-time arbitrary code, credential exfiltration, obfuscated dynamic execution), and the PR description contains the required links."
|
||||
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."
|
||||
HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
|
||||
with:
|
||||
script: |
|
||||
@@ -1268,6 +1283,39 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1290,7 +1338,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"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
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 }}
|
||||
@@ -1310,7 +1358,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"
|
||||
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"
|
||||
@@ -1345,7 +1393,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\":\"${{ inputs.pull_request_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\":\"${{ env.PR_NUMBER }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
---
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pull_request_number:
|
||||
description: "Pull request number to (re-)check"
|
||||
required: true
|
||||
type: number
|
||||
workflow_run:
|
||||
workflows: ["Check requirements (deterministic)"]
|
||||
types: [completed]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
network:
|
||||
allowed:
|
||||
- python
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [default]
|
||||
toolsets: [default, actions]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
max: 1
|
||||
target: ${{ inputs.pull_request_number }}
|
||||
target: "${{ env.PR_NUMBER }}"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.pull_request_number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
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 }}
|
||||
- name: Extract PR number from artifact
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
run: |
|
||||
PR=$(python3 -c 'import json,sys;print(json.load(open("/tmp/gh-aw/deterministic/results.json"))["pr_number"])')
|
||||
echo "PR_NUMBER=${PR}" >> "${GITHUB_ENV}"
|
||||
post-steps:
|
||||
- name: Verify agent produced an add_comment safe-output
|
||||
if: always()
|
||||
if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
run: |
|
||||
OUTPUT=/tmp/gh-aw/agent_output.json
|
||||
if [ ! -f "${OUTPUT}" ]; then
|
||||
@@ -41,557 +53,201 @@ post-steps:
|
||||
exit 1
|
||||
fi
|
||||
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), the
|
||||
package source does not show obvious signs of malicious behavior (Home
|
||||
Assistant secret/config access, download-and-execute, install-time
|
||||
arbitrary code, credential exfiltration, obfuscated dynamic execution),
|
||||
and the PR description contains the required links.
|
||||
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.
|
||||
---
|
||||
|
||||
# Check requirements
|
||||
|
||||
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.
|
||||
|
||||
## Context
|
||||
|
||||
- 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).
|
||||
|
||||
## Step 1 — Identify Changed Packages
|
||||
|
||||
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.
|
||||
|
||||
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`
|
||||
|
||||
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.
|
||||
|
||||
Record the **old version** and **new version** for every version bump — you
|
||||
will need these values in Step 4.
|
||||
|
||||
|
||||
## Step 2 — Check License via PyPI
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
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.
|
||||
|
||||
## Step 2b — Verify PyPI Release Was Uploaded by CI
|
||||
|
||||
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. 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".
|
||||
|
||||
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.
|
||||
|
||||
## Step 3 — Identify Repository URL
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
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.
|
||||
|
||||
## Step 4 — Check PR Description
|
||||
|
||||
Read the PR body from the GitHub API for PR
|
||||
#${{ inputs.pull_request_number }}. Extract all URLs present in the PR body.
|
||||
|
||||
### 4a — New packages: repository link required
|
||||
|
||||
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).
|
||||
|
||||
- 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."
|
||||
|
||||
### 4b — Version bumps: changelog or diff link required
|
||||
|
||||
For **version bumps**: the PR description must contain a link to a changelog,
|
||||
release notes page, or a diff/comparison URL that references the **correct
|
||||
versions** being bumped (old → new).
|
||||
|
||||
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 string and
|
||||
new version string 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. If no URL matches, check if the PR body contains any changelog/diff link at
|
||||
all for this package.
|
||||
|
||||
Outcome:
|
||||
- ✅ — a URL pointing to the correct repo with version references covering the
|
||||
exact bump (X → Y).
|
||||
- ⚠️ — a changelog/diff link exists but does not clearly reference the correct
|
||||
versions or the correct repository; explain what was found and what is
|
||||
expected.
|
||||
- ❌ — no changelog or diff link found at all in the PR description for this
|
||||
package.
|
||||
|
||||
### 4c — Diff consistency check
|
||||
|
||||
For each **version bump**, verify that the version change recorded in the diff
|
||||
(Step 1) is internally consistent:
|
||||
- The `-` line must contain the old version and the `+` line must contain the
|
||||
new version for the same package name.
|
||||
- Flag ❌ if the diff shows a downgrade (new version < old version) without an
|
||||
explanation, or if the version strings cannot be parsed.
|
||||
|
||||
## Step 5 — Verify Source Repository is Publicly Accessible
|
||||
|
||||
Before inspecting the release pipeline, confirm that the source repository
|
||||
identified in Step 3 is publicly reachable.
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
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.**
|
||||
|
||||
## Step 6 — Check Release Pipeline Sanity
|
||||
|
||||
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.
|
||||
|
||||
### GitHub repositories (`github.com`)
|
||||
|
||||
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
|
||||
(flag ❌ if a long-lived API token is used instead of OIDC).
|
||||
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined,
|
||||
❌ if a static secret token is the only credential.
|
||||
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."
|
||||
|
||||
### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
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 or protected CI variables are used, ⚠️ if the method
|
||||
cannot be determined, ❌ if credentials appear to be insecure.
|
||||
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."
|
||||
|
||||
### Other code hosting providers
|
||||
|
||||
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."
|
||||
|
||||
## Step 7 — Security Sanity Check
|
||||
|
||||
This step is a **baseline check only** — a cheap first pass intended to catch
|
||||
the most obvious supply-chain red flags. It is not a security review, not a
|
||||
malware audit, and not a substitute for human judgement. A clean result here
|
||||
means "nothing obvious stood out in a quick scan", not "this package is
|
||||
safe". Reviewers and maintainers remain responsible for the real security
|
||||
assessment.
|
||||
|
||||
For each new or bumped package, perform a lightweight scan of the package's
|
||||
source for behavior patterns that have historically appeared in supply-chain
|
||||
attacks. The goal is to surface suspicious code paths so a human reviewer can
|
||||
decide.
|
||||
|
||||
Use the source repository URL recorded in Step 3. Skip this step and mark `—`
|
||||
when:
|
||||
- The repository is not publicly accessible (Step 5 failed), **or**
|
||||
- The host is neither GitHub nor GitLab and no CI configuration files were
|
||||
retrievable in Step 6.
|
||||
|
||||
### 7a — Fetch a representative slice of the source
|
||||
|
||||
- For **GitHub** repos:
|
||||
1. Resolve the default branch via `GET /repos/{owner}/{repo}`.
|
||||
2. List the tree with
|
||||
`GET /repos/{owner}/{repo}/git/trees/{default_branch}?recursive=1`.
|
||||
3. Identify the package's actual Python module directory (`{package_name}/`
|
||||
or `src/{package_name}/`, normalising `-` ↔ `_`).
|
||||
- For **GitLab** repos use the equivalent REST API calls; for any non-GitHub
|
||||
host fall back to `web-fetch` of raw file URLs.
|
||||
- Fetch the **raw contents** of:
|
||||
- `setup.py` if present — install-time code runs on every consumer machine.
|
||||
- `pyproject.toml` — inspect `[build-system]` and any custom build backend.
|
||||
- The package's `__init__.py`.
|
||||
- Up to **8** additional Python files inside the package directory,
|
||||
prioritising files referenced from `entry_points`, plus any file whose
|
||||
name suggests bootstrap, loader, or self-update behavior
|
||||
(`update*.py`, `loader*.py`, `bootstrap*.py`, `_native.py`,
|
||||
`_post_install*.py`, etc.).
|
||||
|
||||
If the source tree is too large to inspect within the available API budget,
|
||||
inspect at least `setup.py`, `pyproject.toml`, and the package's
|
||||
`__init__.py`, then mark this step ⚠️ with a note that only a partial scan
|
||||
was performed.
|
||||
|
||||
### 7b — Patterns to flag
|
||||
|
||||
Reason from principles, not a fixed checklist. For each fetched file, ask:
|
||||
*would a well-behaved Python library that does what this package's PyPI
|
||||
description claims to do need to do this?* If the answer is "no" or
|
||||
"unclear", record a finding. The categories below describe the **shape** of
|
||||
concerning behavior; the specific APIs, filenames, and storage keys mentioned
|
||||
are illustrative examples — treat any equivalent construct (including ones
|
||||
that did not exist when this workflow was written) the same way.
|
||||
|
||||
For every finding include the file path, line number, a snippet
|
||||
(≤ 120 chars), a permalink of the form
|
||||
`https://github.com/{owner}/{repo}/blob/{sha}/{path}#L{line}` (or the
|
||||
GitLab equivalent), and one sentence explaining why the behavior is out of
|
||||
scope for the package's stated purpose.
|
||||
|
||||
1. **Reaches outside the package's declared scope into Home Assistant
|
||||
internals.**
|
||||
A third-party library should interact with Home Assistant only through
|
||||
the public, documented Python API it imports — never by touching the
|
||||
filesystem of `config_dir` or by reading internal authentication /
|
||||
session state. Flag any code that opens, reads, writes, or resolves paths
|
||||
to artifacts it does not own (top-level YAML it did not create, anything
|
||||
under `.storage/`, files owned by other integrations / domains), or that
|
||||
reads tokens, refresh tokens, auth providers, or other internal session
|
||||
state. Examples like `secrets.yaml`, `.storage/auth*`, `hass.auth`, or
|
||||
`hass.config.path("secrets.yaml")` are illustrative — the principle is
|
||||
*out-of-scope access*, not a static list of names.
|
||||
|
||||
2. **Network input flows into an execution sink (download-and-execute).**
|
||||
Bytes obtained from a remote source must never reach an interpreter.
|
||||
Flag any data-flow path where the response body of a network call
|
||||
(any HTTP / WebSocket / raw-socket client, sync or async) ends up at
|
||||
*any* execution sink: `exec`, `eval`, `compile`, `marshal.loads`,
|
||||
`pickle.loads`, `types.FunctionType`, `importlib.util.spec_from_loader`,
|
||||
`subprocess.*`, `os.system`, shell pipelines such as `curl … | sh`, or
|
||||
a file that is subsequently imported or executed. The same applies to
|
||||
package-manager invocations (`pip install`, `pip download`, …) whose
|
||||
arguments are resolved from network responses at runtime. Future
|
||||
language or stdlib features that achieve the same effect should be
|
||||
treated identically.
|
||||
|
||||
3. **Build-time or install-time code is non-deterministic or non-local.**
|
||||
`setup.py`, `setup.cfg` `cmdclass`, custom PEP 517 backends, and any
|
||||
other build hook must be self-contained: they may only compile and copy
|
||||
files that ship in the source distribution. Flag any build-stage code
|
||||
that opens a network socket, shells out to external binaries, writes
|
||||
outside the build / install tree, or pulls in a build backend whose
|
||||
source is not on PyPI (e.g. referenced via Git URL or local path).
|
||||
|
||||
4. **Reads user secrets and combines them with an egress path.**
|
||||
The concerning shape is *secret-source → outbound-channel*, not any
|
||||
single API. Flag code that reads credential / authentication material
|
||||
from the host (environment variables that look like tokens or API keys,
|
||||
files under the user's home that store credentials, OS keychain APIs,
|
||||
browser-profile directories, Home Assistant token stores) **and** in the
|
||||
same code path sends that data to a destination the package does not
|
||||
need to talk to. Reading secrets alone is not enough; sending data out
|
||||
alone is not enough; the *combination* is the signal.
|
||||
|
||||
5. **Hides what it does from a reader.**
|
||||
Source that a maintainer cannot reasonably review is itself a smell.
|
||||
Flag any pattern where opaque data flows into an execution sink: large
|
||||
encoded / compressed / hex strings (decoded via `base64`, `codecs`,
|
||||
`zlib`, `lzma`, `bytes.fromhex`, or any future equivalent) passed to
|
||||
`exec` / `eval` / `compile` / `__import__`; identifiers assembled at
|
||||
runtime from non-literal pieces and then imported; or any other
|
||||
construct whose evident purpose is to make the real behavior unreadable.
|
||||
|
||||
6. **Hard-coded network destination that does not match the package's
|
||||
stated purpose.**
|
||||
Flag outbound URLs or hosts that do not appear in the package's PyPI
|
||||
`project_urls` and have no obvious connection to its function —
|
||||
especially short-link / paste services, ephemeral tunnels, raw IP
|
||||
addresses, or non-default ports against unknown hosts — and any
|
||||
network call originating from module top-level / `__init__.py` (which
|
||||
executes on import for every consumer).
|
||||
|
||||
If a behavior is clearly out of scope for the package's stated purpose but
|
||||
does not fit any of the categories above, flag it under whichever category
|
||||
fits best and explain in the finding. The list of categories is meant to
|
||||
guide reasoning, not bound it.
|
||||
|
||||
### 7c — Outcome
|
||||
|
||||
Aggregate the findings per package. **The Security column uses a different
|
||||
icon set from the other columns** to make clear that a "pass" here is a
|
||||
baseline result, not a trust signal:
|
||||
|
||||
- ☑️ — baseline scan complete and nothing obvious stood out. This is **not**
|
||||
a ✅ and must not be reported as one. It means "the cheap checks found
|
||||
nothing"; it does not mean the package is safe. Always use ☑️ in this
|
||||
column for the success case — never ✅.
|
||||
- ⚠️ — flagged patterns with plausible legitimate uses (e.g. an integration
|
||||
helper that legitimately reads `configuration.yaml`, or a self-update
|
||||
feature documented upstream). List every match so the reviewer can decide.
|
||||
- ❌ — patterns with no legitimate explanation for a Python dependency, for
|
||||
example: install-time network execution, decode-and-exec of opaque blobs,
|
||||
reads of `secrets.yaml` or `.storage/auth*`, or env-var / token
|
||||
exfiltration to an external host.
|
||||
|
||||
Be precise. Include the file path, line number, snippet, and reasoning for
|
||||
every finding. False positives are expected — when in doubt, prefer ⚠️ with
|
||||
context over ❌. This step is informational and never blocks the workflow on
|
||||
its own; a human reviewer decides whether to merge.
|
||||
|
||||
## Step 8 — Post a Review Comment
|
||||
|
||||
**Always** post a review comment using `add_comment`, regardless of whether
|
||||
packages pass or fail. Use the following structure:
|
||||
|
||||
**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.
|
||||
|
||||
### Comment structure
|
||||
|
||||
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).
|
||||
|
||||
### 8a — Overall summary line
|
||||
|
||||
Begin the comment with a single summary line, before anything else:
|
||||
|
||||
- If everything passed: `All requirements checks passed. ✅`
|
||||
- If there are failures or warnings: `⚠️ Some checks require attention — see the details below.`
|
||||
|
||||
### 8b — Summary table
|
||||
|
||||
Render a compact table where every check column contains **only the status
|
||||
icon**. No explanatory text belongs inside the table cells — all detail goes
|
||||
in the per-package sections below.
|
||||
|
||||
Icon set:
|
||||
- All columns except **Security** use ✅, ⚠️, or ❌.
|
||||
- The **Security** column uses ☑️ (baseline scan passed, *not* a trust
|
||||
signal — see Step 7c), ⚠️, or ❌. Never write ✅ in the Security column.
|
||||
- Use `—` (em dash) in any column when a check was skipped (e.g. Release
|
||||
Pipeline and Security are skipped when the repository is not publicly
|
||||
accessible).
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
> ☑️ in the **Security** column means a baseline scan found nothing
|
||||
> obvious — it is **not** an endorsement. See Step 7 for what the scan
|
||||
> covers and, importantly, what it does not.
|
||||
|
||||
| Package | Type | Old→New | License | Repo Public | CI Upload | Release Pipeline | Security | PR Link | Diff Consistent |
|
||||
|---------|------|---------|---------|-------------|-----------|------------------|----------|---------|-----------------|
|
||||
| PackageA | bump | 1.2.3→1.3.0 | ✅ | ✅ | ✅ | ✅ | ☑️ | ✅ | ✅ |
|
||||
| PackageB | new | —→4.5.6 | ❌ | ✅ | ❌ | ⚠️ | ❌ | ❌ | ✅ |
|
||||
| PackageC | bump | 2.0.0→2.1.0 | ✅ | ❌ | — | — | — | ⚠️ | ✅ |
|
||||
```
|
||||
|
||||
### 8c — 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, the security-scan findings (with file paths, line numbers,
|
||||
and snippets for any matches), the PR link found (or missing), and whether the
|
||||
diff is consistent. 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.
|
||||
- **Security**: ❌ `setup.py` performs a network download and executes the result at install time — see https://github.com/example/packageb/blob/abc123/setup.py#L42 (`exec(urlopen("https://...").read())`).
|
||||
- **PR Link**: ❌ PR description must link to the source repository at https://github.com/example/packageb (a PyPI page link is not sufficient).
|
||||
- **Diff Consistent**: ✅
|
||||
|
||||
</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.
|
||||
- **Security**: ☑️ Baseline scan found nothing obvious in `setup.py`, `pyproject.toml`, `__init__.py`, and 6 additional inspected files. This is not a security review — see Step 7 for what was and was not checked.
|
||||
- **PR Link**: ✅ https://github.com/example/packagea/compare/v1.2.3...v1.3.0
|
||||
- **Diff Consistent**: ✅
|
||||
|
||||
</details>
|
||||
```
|
||||
# Check requirements (AW)
|
||||
|
||||
You are a code review assistant for the Home Assistant project. The
|
||||
deterministic stage has already evaluated every check it can on its own
|
||||
and produced an artifact containing the PR number, per-package check
|
||||
results, and a pre-rendered comment with placeholders. **Your only job is
|
||||
to read that artifact, resolve any `needs_agent` checks, and post the
|
||||
final comment.**
|
||||
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
|
||||
The JSON has this shape:
|
||||
|
||||
- `pr_number` — the PR being checked. The `add_comment` safe-output is
|
||||
already targeted at this PR (the workflow extracted `pr_number` from
|
||||
the artifact and wired it into the safe-output config), so **you do
|
||||
not need to set `item_number` yourself** — just emit `add_comment`
|
||||
with the rendered body.
|
||||
- `needs_agent` — `true` iff any package's check needs resolution.
|
||||
- `packages[]` — one entry per changed package. Each entry has:
|
||||
- `name`, `old_version` (`null` for a newly added package; otherwise the
|
||||
previous pin), `new_version`, `repo_url`, `publisher_kind`.
|
||||
- `checks` — a dict keyed by **check kind** (string). Each value has a
|
||||
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
|
||||
- `rendered_comment` — the final PR comment body, already rendered. For
|
||||
every check whose status is `needs_agent` it contains two placeholders
|
||||
you must replace:
|
||||
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
|
||||
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
|
||||
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
|
||||
in the package's `<details>` block. Replace with
|
||||
`<icon> <one-line explanation>` (the bullet's leading
|
||||
`- **<label>**:` is already rendered — replace only the placeholder).
|
||||
|
||||
You **must not** modify any other content in `rendered_comment`. Do not
|
||||
re-evaluate checks that already have a deterministic status. Do not add
|
||||
or remove packages.
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
|
||||
For each `package` in `packages`:
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
❌ Internal error: the deterministic artifact contains a check kind
|
||||
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
|
||||
instructions for. Update `.github/workflows/check-requirements.md`
|
||||
to add a matching `## Check kind: <check_kind>` section, or remove
|
||||
the kind from the deterministic stage.
|
||||
```
|
||||
|
||||
Then stop. **Do not improvise** a verdict for an unknown check kind.
|
||||
3. Otherwise, follow the instructions in that section. They tell you
|
||||
which icon (✅/⚠️/❌) and one-line explanation to produce.
|
||||
|
||||
## Step 3 — Post the comment
|
||||
|
||||
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
|
||||
in `rendered_comment` with the resolved value.
|
||||
2. Emit the resulting markdown using `add_comment` — set `body` to the
|
||||
merged `rendered_comment` verbatim (the leading
|
||||
`<!-- requirements-check -->` marker must be preserved). The PR
|
||||
target is already set by the workflow; do not pass `item_number`.
|
||||
|
||||
If the artifact's top-level `needs_agent` is `false` (no checks need
|
||||
you), emit `rendered_comment` unchanged.
|
||||
|
||||
## Check instructions
|
||||
|
||||
### Check kind: `repo_public`
|
||||
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
|
||||
1. Read `package.repo_url`.
|
||||
2. Use the `web-fetch` tool to GET that URL.
|
||||
3. Decide the verdict:
|
||||
- HTTP 200, returns a public repository page → ✅
|
||||
`<repo_url> is publicly accessible.`
|
||||
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
|
||||
❌ `Source repository at <repo_url> is not publicly accessible.
|
||||
Home Assistant requires all dependencies to have publicly available
|
||||
source code.`
|
||||
- Any other inconclusive result → ⚠️ with a one-line description.
|
||||
|
||||
If `repo_public` resolves to ❌ for a package, **also** mark that
|
||||
package's `release_pipeline` cell/detail as `—` (em dash) and explain
|
||||
`Skipped because the source repository is not publicly accessible.` —
|
||||
because the release pipeline cannot be inspected without a public repo.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
Verify the PR description contains the right link for the change.
|
||||
|
||||
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
|
||||
field from the artifact.
|
||||
2. Extract all URLs from the body.
|
||||
3. For a **new package** (`package.old_version` is `null`):
|
||||
- The PR body must contain a URL that points at `package.repo_url`
|
||||
(any sub-path of the same `owner/repo` on the same host is
|
||||
acceptable). A PyPI link is **not** sufficient.
|
||||
- ✅ if such a URL is present.
|
||||
- ❌ otherwise:
|
||||
`PR description must link to the source repository at <repo_url>.
|
||||
A PyPI page link is not sufficient.`
|
||||
4. For a **version bump** (`package.old_version` is not `null`):
|
||||
- The PR body must contain a URL on the same host as
|
||||
`package.repo_url` that references **both** `package.old_version`
|
||||
and `package.new_version` (e.g. a GitHub compare URL
|
||||
`compare/vX...vY`, a release / changelog URL containing both
|
||||
versions, etc.).
|
||||
- ✅ if such a URL is present and the versions match the actual bump.
|
||||
- ❌ otherwise:
|
||||
`PR description should link to a changelog or compare URL on
|
||||
<repo_url> that mentions both <old_version> and <new_version>.`
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
|
||||
For each package needing inspection, determine the source repository
|
||||
host from `package.repo_url`, then apply the corresponding checklist.
|
||||
|
||||
#### GitHub repositories (`github.com`)
|
||||
|
||||
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
|
||||
2. Identify any workflow whose name or filename suggests publishing to
|
||||
PyPI (`release`, `publish`, `pypi`, or `deploy`).
|
||||
3. Fetch the workflow file and check:
|
||||
- **Trigger sanity**: triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job —
|
||||
**not** solely `workflow_dispatch` with no environment-protection
|
||||
guard.
|
||||
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
|
||||
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
|
||||
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
|
||||
- **No manual upload bypass**: no ungated `twine upload` or
|
||||
`pip upload`.
|
||||
4. Verdict:
|
||||
- ✅ if OIDC + sane triggers + no bypass.
|
||||
- ⚠️ if static token but version bump, or details unclear.
|
||||
- ❌ if static token on a new package, or only-manual triggers with
|
||||
no environment protection.
|
||||
|
||||
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
|
||||
2. Fetch `.gitlab-ci.yml` via
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
3. Apply the same conceptual checks: tag-only / protected-branch
|
||||
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
|
||||
ungated `twine upload`. Same verdict rules as GitHub.
|
||||
|
||||
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
|
||||
|
||||
1. Use `web-fetch` to retrieve any visible CI configuration
|
||||
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
|
||||
`bitbucket-pipelines.yml`, `.builds/*.yml`).
|
||||
2. Apply the conceptual checks: automated triggers, CI-injected
|
||||
credentials, no manual `twine upload`.
|
||||
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
|
||||
inspected; hosting provider is not GitHub or GitLab.`
|
||||
|
||||
## Notes
|
||||
|
||||
- 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, security,
|
||||
and PR description checks as for production dependencies. Malicious test
|
||||
dependencies still execute on contributor and CI machines.
|
||||
- The Security Sanity Check (Step 7) is a **baseline** check, not a full
|
||||
security review. It is informational only — it surfaces findings for a
|
||||
human reviewer but never blocks the workflow on its own. The Security
|
||||
column intentionally uses ☑️ (and *never* ✅) for the success case so
|
||||
that a passing scan does not read as an endorsement: it only means
|
||||
nothing obvious stood out. A ❌ is a strong signal to a reviewer, not an
|
||||
automatic rejection.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.14.4
|
||||
3.14.5
|
||||
|
||||
Vendored
+3
-3
@@ -132,7 +132,7 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all Requirements",
|
||||
"label": "Install all production Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements_all.txt",
|
||||
"group": {
|
||||
@@ -146,9 +146,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all Test Requirements",
|
||||
"label": "Install all (test & production) Requirements",
|
||||
"type": "shell",
|
||||
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
|
||||
"command": "uv pip install -r requirements_all.txt -r requirements_test.txt",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
|
||||
@@ -81,8 +81,10 @@ 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,6 +91,7 @@ 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",
|
||||
|
||||
@@ -16,6 +16,8 @@ from .entity import AnthropicBaseLLMEntity
|
||||
if TYPE_CHECKING:
|
||||
from . import AnthropicConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ from . import AnthropicConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -38,10 +38,7 @@ rules:
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: |
|
||||
The API does not limit parallel updates.
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
# Gold
|
||||
|
||||
@@ -51,13 +51,11 @@
|
||||
"advanced": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]"
|
||||
},
|
||||
"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%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]"
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
|
||||
},
|
||||
@@ -120,13 +118,11 @@
|
||||
"advanced": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"prompt_caching": "Caching strategy",
|
||||
"temperature": "Temperature"
|
||||
"prompt_caching": "Caching strategy"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "The model to serve the responses.",
|
||||
"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."
|
||||
"prompt_caching": "Optimize your API cost and response times based on your usage."
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Config flow to configure the Arcam FMJ component."""
|
||||
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from arcam.fmj.client import Client, ConnectionFailed
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -29,26 +31,19 @@ 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_check_and_create(self, host: str, port: int) -> ConfigFlowResult:
|
||||
async def _async_try_connect(self, host: str, port: int) -> None:
|
||||
"""Verify the device is reachable."""
|
||||
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]
|
||||
@@ -58,18 +53,36 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], uuid
|
||||
)
|
||||
|
||||
return await self._async_check_and_create(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
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],
|
||||
},
|
||||
)
|
||||
|
||||
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=vol.Schema(fields), errors=errors
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -79,7 +92,10 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.context["title_placeholders"] = placeholders
|
||||
|
||||
if user_input is not None:
|
||||
return await self._async_check_and_create(self.host, self.port)
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({self.host})",
|
||||
data={CONF_HOST: self.host, CONF_PORT: self.port},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm", description_placeholders=placeholders
|
||||
@@ -97,6 +113,11 @@ 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 = DEFAULT_PORT
|
||||
self.port = port
|
||||
return await self.async_step_confirm()
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"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: done
|
||||
exception-translations: todo
|
||||
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: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
|
||||
@@ -205,9 +205,9 @@ class AveaLight(LightEntity):
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
if self._attr_brightness:
|
||||
self._last_brightness = self._attr_brightness
|
||||
self._light.set_brightness(0)
|
||||
self._attr_is_on = False
|
||||
self._attr_brightness = 0
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch new state data for this light."""
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["avea"],
|
||||
"requirements": ["avea==1.6.1"]
|
||||
"requirements": ["avea==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ 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: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
|
||||
@@ -75,11 +75,13 @@ 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
|
||||
@@ -90,6 +92,7 @@ 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
|
||||
@@ -118,6 +121,7 @@ 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()
|
||||
@@ -155,6 +159,7 @@ 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)
|
||||
|
||||
@@ -181,6 +186,7 @@ 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,6 +89,7 @@ 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: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed using the provided key ID and application key."
|
||||
},
|
||||
"invalid_bucket_name": {
|
||||
"message": "Bucket does not exist or is not writable by the provided credentials."
|
||||
},
|
||||
|
||||
@@ -169,6 +169,7 @@ 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,
|
||||
@@ -190,6 +191,7 @@ 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,8 +19,8 @@
|
||||
"bleak-retry-connector==4.6.0",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==4.0.4",
|
||||
"bluetooth-data-tools==1.29.11",
|
||||
"dbus-fast==5.0.0",
|
||||
"habluetooth==6.1.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ 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,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.2.1"],
|
||||
"requirements": ["python-bsblan==6.0.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -17,6 +17,8 @@ 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__)
|
||||
@@ -32,7 +34,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=30,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
try:
|
||||
await hass.async_add_executor_job(client.principal)
|
||||
|
||||
@@ -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
|
||||
from .const import DOMAIN, TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -65,6 +65,7 @@ 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)
|
||||
@@ -75,6 +76,9 @@ 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"
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "caldav"
|
||||
TIMEOUT: Final = 30
|
||||
|
||||
@@ -7,6 +7,7 @@ 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] = [
|
||||
@@ -24,7 +25,9 @@ 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(
|
||||
f"Could not find Casper Glow device with address {address}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"address": address},
|
||||
)
|
||||
|
||||
glow = CasperGlow(ble_device)
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,42 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self,
|
||||
user_input: Mapping[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing entry."""
|
||||
self._errors = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input.get(CONF_PORT, DEFAULT_PORT)
|
||||
|
||||
if (
|
||||
host != reconfigure_entry.data[CONF_HOST]
|
||||
or port != reconfigure_entry.data[CONF_PORT]
|
||||
):
|
||||
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
if await self._test_connection(user_input):
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={CONF_HOST: host, CONF_PORT: port},
|
||||
unique_id=f"{host}:{port}",
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
),
|
||||
user_input or reconfigure_entry.data,
|
||||
),
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"import_failed": "Import from config failed"
|
||||
"import_failed": "Import from config failed",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"connection_refused": "Connection refused when connecting to host",
|
||||
@@ -11,6 +12,13 @@
|
||||
"resolve_failed": "This host cannot be resolved"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"title": "Reconfigure the certificate to test"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
@@ -17,6 +17,8 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
APPLICATION_NAME,
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
@@ -45,10 +47,6 @@ 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"
|
||||
|
||||
@@ -32,28 +32,28 @@ set_temperature:
|
||||
max: 250
|
||||
step: 0.1
|
||||
mode: box
|
||||
target_temp_high:
|
||||
filter:
|
||||
supported_features:
|
||||
- climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
advanced: true
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 250
|
||||
step: 0.1
|
||||
mode: box
|
||||
target_temp_low:
|
||||
filter:
|
||||
supported_features:
|
||||
- climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
advanced: true
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 250
|
||||
step: 0.1
|
||||
mode: box
|
||||
temperature_range:
|
||||
fields:
|
||||
target_temp_high:
|
||||
filter:
|
||||
supported_features:
|
||||
- climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 250
|
||||
step: 0.1
|
||||
mode: box
|
||||
target_temp_low:
|
||||
filter:
|
||||
supported_features:
|
||||
- climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 250
|
||||
step: 0.1
|
||||
mode: box
|
||||
hvac_mode:
|
||||
selector:
|
||||
state:
|
||||
|
||||
@@ -373,7 +373,12 @@
|
||||
"name": "Target temperature"
|
||||
}
|
||||
},
|
||||
"name": "Set thermostat target temperature"
|
||||
"name": "Set thermostat target temperature",
|
||||
"sections": {
|
||||
"temperature_range": {
|
||||
"name": "Temperature range"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Toggles a thermostat on/off.",
|
||||
|
||||
@@ -94,7 +94,7 @@ rules:
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
|
||||
@@ -65,6 +65,7 @@ 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",
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
"message": "Timeout trying to execute command: {command}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"platform_yaml_not_supported": {
|
||||
"description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.",
|
||||
"title": "Platform YAML is not supported in Command Line"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"description": "Reloads command line configuration from the YAML-configuration.",
|
||||
|
||||
@@ -4,7 +4,9 @@ import asyncio
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
async_create_platform_config_not_supported_issue,
|
||||
)
|
||||
from homeassistant.helpers.template import Template
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -98,13 +100,11 @@ def create_platform_yaml_not_supported_issue(
|
||||
hass: HomeAssistant, platform_domain: str
|
||||
) -> None:
|
||||
"""Create an issue when platform yaml is used."""
|
||||
async_create_issue(
|
||||
async_create_platform_config_not_supported_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"{platform_domain}_platform_yaml_not_supported",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.ERROR,
|
||||
translation_key="platform_yaml_not_supported",
|
||||
translation_placeholders={"platform": platform_domain},
|
||||
platform_domain,
|
||||
yaml_config_under_integration_supported=True,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/command_line/",
|
||||
logger=LOGGER,
|
||||
)
|
||||
|
||||
@@ -91,6 +91,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Compensation sensor."""
|
||||
hass.data[DATA_COMPENSATION] = {}
|
||||
|
||||
# Exit early if no compensations are configured using the compensation: key in configuration.yaml.
|
||||
# This allows us to create an issue if platform: compensation is present in the sensor: section.
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
for compensation, conf in config[DOMAIN].items():
|
||||
_LOGGER.debug("Setup %s.%s", DOMAIN, compensation)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import numpy as np
|
||||
from homeassistant.components.sensor import (
|
||||
ATTR_STATE_CLASS,
|
||||
CONF_STATE_CLASS,
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@@ -31,7 +32,10 @@ from homeassistant.core import (
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_create_platform_config_not_supported_issue,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
@@ -41,6 +45,7 @@ from .const import (
|
||||
CONF_PRECISION,
|
||||
DATA_COMPENSATION,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -58,6 +63,14 @@ async def async_setup_platform(
|
||||
) -> None:
|
||||
"""Set up the Compensation sensor."""
|
||||
if discovery_info is None:
|
||||
async_create_platform_config_not_supported_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SENSOR_DOMAIN,
|
||||
yaml_config_under_integration_supported=True,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/compensation/",
|
||||
logger=_LOGGER,
|
||||
)
|
||||
return
|
||||
|
||||
compensation: str = discovery_info[CONF_COMPENSATION]
|
||||
|
||||
@@ -5,6 +5,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .config_entry import ( # noqa: F401
|
||||
BaseScannerEntity,
|
||||
ScannerEntity,
|
||||
ScannerEntityDescription,
|
||||
TrackerEntity,
|
||||
|
||||
@@ -166,7 +166,11 @@ def _async_register_mac(
|
||||
|
||||
|
||||
class BaseTrackerEntity(Entity):
|
||||
"""Represent a tracked device."""
|
||||
"""Represent a tracked device.
|
||||
|
||||
Not intended to be directly inherited by integrations. Integrations should
|
||||
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
|
||||
"""
|
||||
|
||||
_attr_device_info: None = None
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
@@ -304,6 +308,28 @@ class TrackerEntity(
|
||||
return attr
|
||||
|
||||
|
||||
class BaseScannerEntity(BaseTrackerEntity):
|
||||
"""Base class for a tracked device that can be connected or disconnected.
|
||||
|
||||
Unlike ScannerEntity, this entity does not make assumptions about MAC
|
||||
addresses being used to identify the device.
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
if self.is_connected is None:
|
||||
return None
|
||||
if self.is_connected:
|
||||
return STATE_HOME
|
||||
return STATE_NOT_HOME
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool | None:
|
||||
"""Return true if the device is connected."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""A class that describes tracker entities."""
|
||||
|
||||
@@ -316,7 +342,7 @@ CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
|
||||
|
||||
|
||||
class ScannerEntity(
|
||||
BaseTrackerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
|
||||
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
|
||||
):
|
||||
"""Base class for a tracked device that is on a scanned network."""
|
||||
|
||||
@@ -341,18 +367,6 @@ class ScannerEntity(
|
||||
"""Return hostname of the device."""
|
||||
return self._attr_hostname
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Return the state of the device."""
|
||||
if self.is_connected:
|
||||
return STATE_HOME
|
||||
return STATE_NOT_HOME
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return true if the device is connected to the network."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return unique ID of the entity."""
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.1",
|
||||
"aiodiscover==2.7.1",
|
||||
"aiodiscover==3.2.0",
|
||||
"cached-ipaddress==1.0.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dnsip",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["aiodns==4.0.3"]
|
||||
"requirements": ["aiodns==4.0.4"]
|
||||
}
|
||||
|
||||
@@ -60,7 +60,11 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise ConfigEntryError(f"Duco API error: {err}") from err
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def _async_update_data(self) -> DucoData:
|
||||
"""Fetch node data from the Duco box."""
|
||||
|
||||
@@ -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,),
|
||||
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
|
||||
),
|
||||
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,),
|
||||
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
|
||||
),
|
||||
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),
|
||||
node_types=(NodeType.BSRH, NodeType.UCRH, NodeType.VLVRH, NodeType.VLVCO2RH),
|
||||
),
|
||||
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),
|
||||
node_types=(NodeType.BSRH, NodeType.UCRH, NodeType.VLVRH, NodeType.VLVCO2RH),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -353,6 +353,7 @@ 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",
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.const import SERVICE_ALARM_ARM_VACATION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -43,8 +44,6 @@ DISPLAY_MESSAGE_SERVICE_SCHEMA: VolDictType = {
|
||||
}
|
||||
|
||||
SERVICE_ALARM_DISPLAY_MESSAGE = "alarm_display_message"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_ALARM_ARM_VACATION = "alarm_arm_vacation"
|
||||
SERVICE_ALARM_ARM_HOME_INSTANT = "alarm_arm_home_instant"
|
||||
SERVICE_ALARM_ARM_NIGHT_INSTANT = "alarm_arm_night_instant"
|
||||
SERVICE_ALARM_BYPASS = "alarm_bypass"
|
||||
|
||||
@@ -10,6 +10,7 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_DEVICE,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PREFIX,
|
||||
@@ -32,9 +33,6 @@ from .discovery import (
|
||||
async_update_entry_from_discovery,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE = "device"
|
||||
|
||||
NON_SECURE_PORT = 2101
|
||||
SECURE_PORT = 2601
|
||||
STANDARD_PORTS = {NON_SECURE_PORT, SECURE_PORT}
|
||||
|
||||
@@ -88,7 +88,7 @@ rules:
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
|
||||
@@ -364,6 +364,7 @@ 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.2",
|
||||
"aioesphomeapi==45.0.4",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
|
||||
@@ -99,14 +99,16 @@ increase_speed:
|
||||
supported_features:
|
||||
- fan.FanEntityFeature.SET_SPEED
|
||||
fields:
|
||||
percentage_step:
|
||||
advanced: true
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
unit_of_measurement: "%"
|
||||
additional_fields:
|
||||
collapsed: true
|
||||
fields:
|
||||
percentage_step:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
unit_of_measurement: "%"
|
||||
|
||||
decrease_speed:
|
||||
target:
|
||||
@@ -115,11 +117,13 @@ decrease_speed:
|
||||
supported_features:
|
||||
- fan.FanEntityFeature.SET_SPEED
|
||||
fields:
|
||||
percentage_step:
|
||||
advanced: true
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
unit_of_measurement: "%"
|
||||
additional_fields:
|
||||
collapsed: true
|
||||
fields:
|
||||
percentage_step:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
unit_of_measurement: "%"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"section_additional_fields_name": "Additional options",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -109,7 +110,12 @@
|
||||
"name": "Decrement"
|
||||
}
|
||||
},
|
||||
"name": "Decrease fan speed"
|
||||
"name": "Decrease fan speed",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::fan::common::section_additional_fields_name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"increase_speed": {
|
||||
"description": "Increases the speed of a fan.",
|
||||
@@ -119,7 +125,12 @@
|
||||
"name": "Increment"
|
||||
}
|
||||
},
|
||||
"name": "Increase fan speed"
|
||||
"name": "Increase fan speed",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::fan::common::section_additional_fields_name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oscillate": {
|
||||
"description": "Controls the oscillation of a fan.",
|
||||
|
||||
@@ -1,19 +1,82 @@
|
||||
"""Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from freebox_api.exceptions import HttpRequestError
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .router import FreeboxConfigEntry, FreeboxRouter, get_api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
# Old entity name suffixes that need rewriting to the entity description key.
|
||||
# Format: (platform, old name suffix, new key)
|
||||
_STATIC_UNIQUE_ID_MIGRATIONS: tuple[tuple[Platform, str, str], ...] = (
|
||||
(Platform.SENSOR, "Freebox download speed", "rate_down"),
|
||||
(Platform.SENSOR, "Freebox upload speed", "rate_up"),
|
||||
(Platform.SENSOR, "Freebox missed calls", "missed"),
|
||||
(Platform.BUTTON, "Reboot Freebox", "reboot"),
|
||||
(Platform.BUTTON, "Mark calls as read", "mark_calls_as_read"),
|
||||
(Platform.SWITCH, "Freebox WiFi", "wifi"),
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool:
|
||||
"""Migrate old config entries."""
|
||||
if entry.version < 2:
|
||||
api = await get_api(hass, entry.data[CONF_HOST])
|
||||
try:
|
||||
await api.open(entry.data[CONF_HOST], entry.data[CONF_PORT])
|
||||
freebox_config = await api.system.get_config()
|
||||
except HttpRequestError:
|
||||
_LOGGER.warning(
|
||||
"Unable to migrate Freebox entry to version 2: cannot reach the router"
|
||||
)
|
||||
return False
|
||||
finally:
|
||||
await api.close()
|
||||
|
||||
mac: str = freebox_config["mac"]
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
migrations: list[tuple[Platform, str, str]] = [
|
||||
(platform, f"{mac} {old_suffix}", f"{mac} {new_key}")
|
||||
for platform, old_suffix, new_key in _STATIC_UNIQUE_ID_MIGRATIONS
|
||||
]
|
||||
migrations.extend(
|
||||
(
|
||||
Platform.SENSOR,
|
||||
f"{mac} Freebox {sensor['name']}",
|
||||
f"{mac} {sensor['id']}",
|
||||
)
|
||||
for sensor in freebox_config.get("sensors", [])
|
||||
)
|
||||
|
||||
for platform, old_uid, new_uid in migrations:
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
platform, DOMAIN, old_uid
|
||||
):
|
||||
entity_registry.async_update_entity(entity_id, new_unique_id=new_uid)
|
||||
_LOGGER.debug(
|
||||
"Migrated %s unique_id from %s to %s",
|
||||
entity_id,
|
||||
old_uid,
|
||||
new_uid,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, version=2)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool:
|
||||
"""Set up Freebox entry."""
|
||||
|
||||
@@ -48,6 +48,7 @@ class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity):
|
||||
"""Representation of a Freebox alarm."""
|
||||
|
||||
_attr_code_arm_required = False
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None:
|
||||
"""Initialize an alarm."""
|
||||
|
||||
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
|
||||
BinarySensorEntityDescription(
|
||||
key="raid_degraded",
|
||||
name="degraded",
|
||||
translation_key="raid_degraded",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
@@ -68,7 +68,7 @@ async def async_setup_entry(
|
||||
class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity):
|
||||
"""Representation of a Freebox binary sensor."""
|
||||
|
||||
_sensor_name = "trigger"
|
||||
_endpoint_name = "trigger"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -79,9 +79,11 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity):
|
||||
"""Initialize a Freebox binary sensor."""
|
||||
super().__init__(router, node, sub_node)
|
||||
self._command_id = self.get_command_id(
|
||||
node["type"]["endpoints"], "signal", self._sensor_name
|
||||
node["type"]["endpoints"], "signal", self._endpoint_name
|
||||
)
|
||||
self._attr_is_on = self._edit_state(
|
||||
self.get_value("signal", self._endpoint_name)
|
||||
)
|
||||
self._attr_is_on = self._edit_state(self.get_value("signal", self._sensor_name))
|
||||
|
||||
async def async_update_signal(self) -> None:
|
||||
"""Update name & state."""
|
||||
@@ -91,10 +93,10 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity):
|
||||
await FreeboxHomeEntity.async_update_signal(self)
|
||||
|
||||
def _edit_state(self, state: bool | None) -> bool | None:
|
||||
"""Edit state depending on sensor name."""
|
||||
"""Edit state depending on endpoint name."""
|
||||
if state is None:
|
||||
return None
|
||||
if self._sensor_name == "trigger":
|
||||
if self._endpoint_name == "trigger":
|
||||
return not state
|
||||
return state
|
||||
|
||||
@@ -103,12 +105,14 @@ class FreeboxPirSensor(FreeboxHomeBinarySensor):
|
||||
"""Representation of a Freebox motion binary sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.MOTION
|
||||
_attr_name = None
|
||||
|
||||
|
||||
class FreeboxDwsSensor(FreeboxHomeBinarySensor):
|
||||
"""Representation of a Freebox door opener binary sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
_attr_name = None
|
||||
|
||||
|
||||
class FreeboxCoverSensor(FreeboxHomeBinarySensor):
|
||||
@@ -121,14 +125,15 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor):
|
||||
_attr_device_class = BinarySensorDeviceClass.SAFETY
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_translation_key = "cover"
|
||||
|
||||
_sensor_name = "cover"
|
||||
_endpoint_name = "cover"
|
||||
|
||||
def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None:
|
||||
"""Initialize a cover for another device."""
|
||||
cover_node = next(
|
||||
filter(
|
||||
lambda x: x["name"] == self._sensor_name and x["ep_type"] == "signal",
|
||||
lambda x: x["name"] == self._endpoint_name and x["ep_type"] == "signal",
|
||||
node["type"]["endpoints"],
|
||||
),
|
||||
None,
|
||||
@@ -153,7 +158,7 @@ class FreeboxRaidDegradedSensor(BinarySensorEntity):
|
||||
self._router = router
|
||||
self._attr_device_info = router.device_info
|
||||
self._raid = raid
|
||||
self._attr_name = f"Raid array {raid['id']} {description.name}"
|
||||
self._attr_translation_placeholders = {"id": str(raid["id"])}
|
||||
self._attr_unique_id = (
|
||||
f"{router.mac} {description.key} {raid['name']} {raid['id']}"
|
||||
)
|
||||
|
||||
@@ -25,14 +25,13 @@ class FreeboxButtonEntityDescription(ButtonEntityDescription):
|
||||
BUTTON_DESCRIPTIONS: tuple[FreeboxButtonEntityDescription, ...] = (
|
||||
FreeboxButtonEntityDescription(
|
||||
key="reboot",
|
||||
name="Reboot Freebox",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
async_press=lambda router: router.reboot(),
|
||||
),
|
||||
FreeboxButtonEntityDescription(
|
||||
key="mark_calls_as_read",
|
||||
name="Mark calls as read",
|
||||
translation_key="mark_calls_as_read",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
async_press=lambda router: router.call.mark_calls_log_as_read(),
|
||||
),
|
||||
@@ -55,6 +54,7 @@ async def async_setup_entry(
|
||||
class FreeboxButton(ButtonEntity):
|
||||
"""Representation of a Freebox button."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: FreeboxButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
@@ -64,7 +64,7 @@ class FreeboxButton(ButtonEntity):
|
||||
self.entity_description = description
|
||||
self._router = router
|
||||
self._attr_device_info = router.device_info
|
||||
self._attr_unique_id = f"{router.mac} {description.name}"
|
||||
self._attr_unique_id = f"{router.mac} {description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
"""Support for Freebox cameras."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.camera import CameraEntityFeature
|
||||
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, CONF_INPUT
|
||||
from homeassistant.components.ffmpeg.camera import ( # pylint: disable=home-assistant-component-root-import
|
||||
DEFAULT_ARGUMENTS,
|
||||
FFmpegCamera,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from aiohttp import web
|
||||
from haffmpeg.camera import CameraMjpeg
|
||||
from haffmpeg.tools import IMAGE_JPEG
|
||||
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.components.ffmpeg import DATA_FFMPEG, FFmpegManager, async_get_image
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -19,7 +18,7 @@ from .const import ATTR_DETECTION, FreeboxHomeCategory
|
||||
from .entity import FreeboxHomeEntity
|
||||
from .router import FreeboxConfigEntry, FreeboxRouter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_FFMPEG_ARGUMENTS = "-pred 1"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -63,25 +62,21 @@ def add_entities(
|
||||
async_add_entities(new_tracked, True)
|
||||
|
||||
|
||||
class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera):
|
||||
class FreeboxCamera(FreeboxHomeEntity, Camera):
|
||||
"""Representation of a Freebox camera."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_supported_features = CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize a camera."""
|
||||
|
||||
super().__init__(router, node)
|
||||
device_info = {
|
||||
CONF_NAME: node["label"].strip(),
|
||||
CONF_INPUT: node["props"]["Stream"],
|
||||
CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS,
|
||||
}
|
||||
FFmpegCamera.__init__(self, hass, device_info)
|
||||
Camera.__init__(self)
|
||||
|
||||
self._supported_features = (
|
||||
CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM
|
||||
)
|
||||
self._ffmpeg: FFmpegManager = hass.data[DATA_FFMPEG]
|
||||
self._input: str = node["props"]["Stream"]
|
||||
|
||||
self._command_motion_detection = self.get_command_id(
|
||||
node["type"]["endpoints"], "slot", ATTR_DETECTION
|
||||
@@ -89,6 +84,39 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera):
|
||||
self._attr_extra_state_attributes = {}
|
||||
self.update_node(node)
|
||||
|
||||
async def stream_source(self) -> str:
|
||||
"""Return the stream source."""
|
||||
return self._input.split(" ")[-1]
|
||||
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return a still image response from the camera."""
|
||||
return await async_get_image(
|
||||
self.hass,
|
||||
self._input,
|
||||
output_format=IMAGE_JPEG,
|
||||
extra_cmd=_FFMPEG_ARGUMENTS,
|
||||
)
|
||||
|
||||
async def handle_async_mjpeg_stream(
|
||||
self, request: web.Request
|
||||
) -> web.StreamResponse:
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
stream = CameraMjpeg(self._ffmpeg.binary)
|
||||
await stream.open_camera(self._input, extra_cmd=_FFMPEG_ARGUMENTS)
|
||||
|
||||
try:
|
||||
stream_reader = await stream.get_reader()
|
||||
return await async_aiohttp_proxy_stream(
|
||||
self.hass,
|
||||
request,
|
||||
stream_reader,
|
||||
self._ffmpeg.ffmpeg_stream_content_type,
|
||||
)
|
||||
finally:
|
||||
await stream.close()
|
||||
|
||||
async def async_enable_motion_detection(self) -> None:
|
||||
"""Enable motion detection in the camera."""
|
||||
if await self.set_home_endpoint_value(self._command_motion_detection, True):
|
||||
@@ -102,25 +130,17 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera):
|
||||
async def async_update_signal(self) -> None:
|
||||
"""Update the camera node."""
|
||||
self.update_node(self._router.home_devices[self._id])
|
||||
self.async_write_ha_state()
|
||||
await super().async_update_signal()
|
||||
|
||||
def update_node(self, node: dict[str, Any]) -> None:
|
||||
"""Update params."""
|
||||
self._name = node["label"].strip()
|
||||
self._attr_is_streaming = node["status"] == "active"
|
||||
|
||||
# Get status
|
||||
if self._node["status"] == "active":
|
||||
self._attr_is_streaming = True
|
||||
else:
|
||||
self._attr_is_streaming = False
|
||||
|
||||
# Parse all endpoints values
|
||||
for endpoint in filter(
|
||||
lambda x: x["ep_type"] == "signal", node["show_endpoints"]
|
||||
):
|
||||
self._attr_extra_state_attributes[endpoint["name"]] = endpoint["value"]
|
||||
|
||||
# Get motion detection status
|
||||
self._attr_motion_detection_enabled = self._attr_extra_state_attributes[
|
||||
ATTR_DETECTION
|
||||
]
|
||||
|
||||
@@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize config flow."""
|
||||
|
||||
@@ -55,6 +55,7 @@ def add_entities(
|
||||
class FreeboxDevice(ScannerEntity):
|
||||
"""Representation of a Freebox device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, router: FreeboxRouter, device: dict[str, Any]) -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -16,6 +17,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class FreeboxHomeEntity(Entity):
|
||||
"""Representation of a Freebox base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
router: FreeboxRouter,
|
||||
@@ -27,12 +30,9 @@ class FreeboxHomeEntity(Entity):
|
||||
self._node = node
|
||||
self._sub_node = sub_node
|
||||
self._id = node["id"]
|
||||
self._attr_name = node["label"].strip()
|
||||
self._device_name = self._attr_name
|
||||
self._attr_unique_id = f"{self._router.mac}-node_{self._id}"
|
||||
|
||||
if sub_node is not None:
|
||||
self._attr_name += " " + sub_node["label"].strip()
|
||||
self._attr_unique_id += "-" + sub_node["name"].strip()
|
||||
|
||||
self._available = True
|
||||
@@ -52,7 +52,7 @@ class FreeboxHomeEntity(Entity):
|
||||
identifiers={(DOMAIN, self._id)},
|
||||
manufacturer=self._manufacturer,
|
||||
model=self._model,
|
||||
name=self._device_name,
|
||||
name=node["label"].strip(),
|
||||
sw_version=self._firmware,
|
||||
via_device=(DOMAIN, router.mac),
|
||||
)
|
||||
@@ -60,13 +60,13 @@ class FreeboxHomeEntity(Entity):
|
||||
async def async_update_signal(self) -> None:
|
||||
"""Update signal."""
|
||||
self._node = self._router.home_devices[self._id]
|
||||
# Update name
|
||||
if self._sub_node is None:
|
||||
self._attr_name = self._node["label"].strip()
|
||||
else:
|
||||
self._attr_name = (
|
||||
self._node["label"].strip() + " " + self._sub_node["label"].strip()
|
||||
)
|
||||
# Propagate Freebox device label changes to the device registry so
|
||||
# the entity stays in sync when users rename it on the Freebox app.
|
||||
device_registry = dr.async_get(self.hass)
|
||||
if device := device_registry.async_get_device(identifiers={(DOMAIN, self._id)}):
|
||||
new_name = self._node["label"].strip()
|
||||
if device.name != new_name:
|
||||
device_registry.async_update_device(device.id, name=new_name)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def set_home_endpoint_value(
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
{
|
||||
"services": {
|
||||
"reboot": {
|
||||
"service": "mdi:restart"
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"missed": {
|
||||
"default": "mdi:phone-missed"
|
||||
},
|
||||
"partition_free_space": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"rate_down": {
|
||||
"default": "mdi:download-network"
|
||||
},
|
||||
"rate_up": {
|
||||
"default": "mdi:upload-network"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"wifi": {
|
||||
"default": "mdi:wifi",
|
||||
"state": {
|
||||
"off": "mdi:wifi-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ class FreeboxRouter:
|
||||
self.supports_raid = True
|
||||
self.raids: dict[int, dict[str, Any]] = {}
|
||||
self.sensors_temperature: dict[str, int] = {}
|
||||
self.sensors_temperature_names: dict[str, str] = {}
|
||||
self.sensors_connection: dict[str, float] = {}
|
||||
self.call_list: list[dict[str, Any]] = []
|
||||
self.home_granted = True
|
||||
@@ -185,7 +186,9 @@ class FreeboxRouter:
|
||||
# temperature sensors in celsius degree.
|
||||
# Name and id of sensors may vary under Freebox devices.
|
||||
for sensor in syst_datas["sensors"]:
|
||||
self.sensors_temperature[sensor["name"]] = sensor.get("value")
|
||||
sensor_id = sensor["id"]
|
||||
self.sensors_temperature[sensor_id] = sensor.get("value")
|
||||
self.sensors_temperature_names[sensor_id] = sensor["name"]
|
||||
|
||||
# Connection sensors
|
||||
connection_datas: dict[str, Any] = await self._api.connection.get_status()
|
||||
|
||||
@@ -25,36 +25,34 @@ _LOGGER = logging.getLogger(__name__)
|
||||
CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="rate_down",
|
||||
name="Freebox download speed",
|
||||
translation_key="rate_down",
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND,
|
||||
icon="mdi:download-network",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="rate_up",
|
||||
name="Freebox upload speed",
|
||||
translation_key="rate_up",
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND,
|
||||
icon="mdi:upload-network",
|
||||
),
|
||||
)
|
||||
|
||||
CALL_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="missed",
|
||||
name="Freebox missed calls",
|
||||
icon="mdi:phone-missed",
|
||||
translation_key="missed",
|
||||
native_unit_of_measurement="calls",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
)
|
||||
|
||||
DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="partition_free_space",
|
||||
name="free space",
|
||||
translation_key="partition_free_space",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
icon="mdi:harddisk",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -77,14 +75,14 @@ async def async_setup_entry(
|
||||
FreeboxSensor(
|
||||
router,
|
||||
SensorEntityDescription(
|
||||
key=sensor_name,
|
||||
name=f"Freebox {sensor_name}",
|
||||
key=sensor_id,
|
||||
name=sensor_name,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
)
|
||||
for sensor_name in router.sensors_temperature
|
||||
for sensor_id, sensor_name in router.sensors_temperature_names.items()
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
@@ -121,6 +119,7 @@ class FreeboxSensor(SensorEntity):
|
||||
"""Representation of a Freebox sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, router: FreeboxRouter, description: SensorEntityDescription
|
||||
@@ -128,7 +127,7 @@ class FreeboxSensor(SensorEntity):
|
||||
"""Initialize a Freebox sensor."""
|
||||
self.entity_description = description
|
||||
self._router = router
|
||||
self._attr_unique_id = f"{router.mac} {description.name}"
|
||||
self._attr_unique_id = f"{router.mac} {description.key}"
|
||||
self._attr_device_info = router.device_info
|
||||
|
||||
@callback
|
||||
@@ -204,7 +203,7 @@ class FreeboxDiskSensor(FreeboxSensor):
|
||||
super().__init__(router, description)
|
||||
self._disk_id = disk["id"]
|
||||
self._partition_id = partition["id"]
|
||||
self._attr_name = f"{partition['label']} {description.name}"
|
||||
self._attr_translation_placeholders = {"partition": partition["label"]}
|
||||
self._attr_unique_id = (
|
||||
f"{router.mac} {description.key} {disk['id']} {partition['id']}"
|
||||
)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Freebox service entries description.
|
||||
|
||||
reboot:
|
||||
@@ -25,10 +25,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reboot": {
|
||||
"description": "Reboots the Freebox.",
|
||||
"name": "Reboot"
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"cover": {
|
||||
"name": "Cover"
|
||||
},
|
||||
"raid_degraded": {
|
||||
"name": "RAID array {id} degraded"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"mark_calls_as_read": {
|
||||
"name": "Mark calls as read"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"missed": {
|
||||
"name": "Missed calls"
|
||||
},
|
||||
"partition_free_space": {
|
||||
"name": "{partition} free space"
|
||||
},
|
||||
"rate_down": {
|
||||
"name": "Download speed"
|
||||
},
|
||||
"rate_up": {
|
||||
"name": "Upload speed"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"wifi": {
|
||||
"name": "Wi-Fi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
SWITCH_DESCRIPTIONS = [
|
||||
SwitchEntityDescription(
|
||||
key="wifi",
|
||||
name="Freebox WiFi",
|
||||
translation_key="wifi",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
)
|
||||
]
|
||||
@@ -41,6 +41,8 @@ async def async_setup_entry(
|
||||
class FreeboxSwitch(SwitchEntity):
|
||||
"""Representation of a freebox switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, router: FreeboxRouter, entity_description: SwitchEntityDescription
|
||||
) -> None:
|
||||
@@ -48,7 +50,7 @@ class FreeboxSwitch(SwitchEntity):
|
||||
self.entity_description = entity_description
|
||||
self._router = router
|
||||
self._attr_device_info = router.device_info
|
||||
self._attr_unique_id = f"{router.mac} {entity_description.name}"
|
||||
self._attr_unique_id = f"{router.mac} {entity_description.key}"
|
||||
|
||||
async def _async_set_state(self, enabled: bool) -> None:
|
||||
"""Turn the switch on or off."""
|
||||
|
||||
@@ -63,7 +63,7 @@ rules:
|
||||
comment: |
|
||||
This integration does not have many entities. All of them are fundamental.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
|
||||
@@ -61,13 +61,20 @@ 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 from ex
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_connecting",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in avm_wrapper.connection.services
|
||||
and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
raise ConfigEntryAuthFailed("Missing UPnP configuration")
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_upnp_disabled",
|
||||
)
|
||||
|
||||
await avm_wrapper.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -185,12 +185,18 @@
|
||||
"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"
|
||||
},
|
||||
@@ -200,9 +206,6 @@
|
||||
"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}"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.humidifier import HumidifierDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
@@ -28,8 +28,6 @@ CONF_SENSOR = "target_sensor"
|
||||
CONF_MIN_HUMIDITY = "min_humidity"
|
||||
CONF_MAX_HUMIDITY = "max_humidity"
|
||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE_CLASS = "device_class"
|
||||
CONF_MIN_DUR = "min_cycle_duration"
|
||||
CONF_DRY_TOLERANCE = "dry_tolerance"
|
||||
CONF_WET_TOLERANCE = "wet_tolerance"
|
||||
|
||||
@@ -8,7 +8,7 @@ import voluptuous as vol
|
||||
from homeassistant.components import fan, switch
|
||||
from homeassistant.components.humidifier import HumidifierDeviceClass
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import CONF_NAME, PERCENTAGE
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, PERCENTAGE
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaConfigFlowHandler,
|
||||
@@ -16,7 +16,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
||||
)
|
||||
|
||||
from . import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_DRY_TOLERANCE,
|
||||
CONF_HUMIDIFIER,
|
||||
CONF_MIN_DUR,
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_MODE,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
@@ -56,7 +57,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from . import (
|
||||
CONF_AWAY_FIXED,
|
||||
CONF_AWAY_HUMIDITY,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_DRY_TOLERANCE,
|
||||
CONF_HUMIDIFIER,
|
||||
CONF_INITIAL_STATE,
|
||||
|
||||
@@ -5,7 +5,11 @@ 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
|
||||
from google_air_quality_api.exceptions import (
|
||||
GoogleAirQualityApiError,
|
||||
InvalidCustomLAQIConfigurationError,
|
||||
)
|
||||
from google_air_quality_api.mapping import AQICategoryMapping
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -18,6 +22,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_COUNTRY,
|
||||
CONF_LATITUDE,
|
||||
CONF_LOCATION,
|
||||
CONF_LONGITUDE,
|
||||
@@ -26,11 +31,28 @@ 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 LocationSelector, LocationSelectorConfig
|
||||
from homeassistant.helpers.selector import (
|
||||
CountrySelector,
|
||||
LocationSelector,
|
||||
LocationSelectorConfig,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
|
||||
from .const import (
|
||||
CONF_ENABLE_CUSTOM_LAQI,
|
||||
CONF_REFERRER,
|
||||
CUSTOM_LAQI,
|
||||
CUSTOM_LOCAL_AQI_OPTIONS,
|
||||
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(
|
||||
{
|
||||
@@ -50,10 +72,31 @@ async def _validate_input(
|
||||
description_placeholders: dict[str, str],
|
||||
) -> bool:
|
||||
try:
|
||||
await api.async_get_current_conditions(
|
||||
lat=user_input[CONF_LOCATION][CONF_LATITUDE],
|
||||
lon=user_input[CONF_LOCATION][CONF_LONGITUDE],
|
||||
)
|
||||
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"
|
||||
except GoogleAirQualityApiError as err:
|
||||
errors["base"] = "cannot_connect"
|
||||
description_placeholders["error_message"] = str(err)
|
||||
@@ -79,6 +122,25 @@ 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),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -123,6 +185,7 @@ 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:
|
||||
@@ -132,10 +195,13 @@ 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={
|
||||
@@ -145,7 +211,7 @@ class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "location",
|
||||
"data": user_input[CONF_LOCATION],
|
||||
"data": subentry_data,
|
||||
"title": user_input[CONF_NAME],
|
||||
"unique_id": None,
|
||||
},
|
||||
@@ -185,7 +251,9 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {
|
||||
"air_quality_coverage_url": AIR_QUALITY_COVERAGE_URL
|
||||
}
|
||||
if user_input is not None:
|
||||
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
|
||||
errors["base"] = "location_already_configured"
|
||||
@@ -202,9 +270,13 @@ 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=user_input[CONF_LOCATION],
|
||||
data=data,
|
||||
)
|
||||
else:
|
||||
user_input = {}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN = "google_air_quality"
|
||||
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
|
||||
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"
|
||||
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
|
||||
|
||||
@@ -10,11 +10,16 @@ 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_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
CONF_ENABLE_CUSTOM_LAQI,
|
||||
CUSTOM_LAQI,
|
||||
CUSTOM_LOCAL_AQI_OPTIONS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,11 +54,27 @@ 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(self.lat, self.long)
|
||||
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,
|
||||
)
|
||||
except GoogleAirQualityApiError as ex:
|
||||
_LOGGER.debug("Cannot fetch air quality data: %s", str(ex))
|
||||
raise UpdateFailed(
|
||||
|
||||
@@ -204,7 +204,6 @@ 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,6 +15,8 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -39,6 +41,20 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,8 +67,11 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -69,6 +88,22 @@
|
||||
"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,6 +100,7 @@ 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]:
|
||||
@@ -107,6 +108,7 @@ 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(
|
||||
@@ -119,6 +121,7 @@ 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(
|
||||
@@ -139,7 +142,9 @@ 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(
|
||||
@@ -160,5 +165,7 @@ 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,6 +84,7 @@ 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",
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ELEVATION,
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
ATTR_MODE,
|
||||
@@ -34,8 +35,6 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_CLIMB = "climb"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_ELEVATION = "elevation"
|
||||
ATTR_SPEED = "speed"
|
||||
ATTR_TOTAL_SATELLITES = "total_satellites"
|
||||
ATTR_USED_SATELLITES = "used_satellites"
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
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.6.0"]
|
||||
"requirements": ["guntamatic==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="domestic_home_water_temperature",
|
||||
translation_key="domestic_home_water_temperature",
|
||||
key="domestic_hot_water_0_temperature",
|
||||
translation_key="domestic_hot_water_0_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
"dhw": "Domestic hot water",
|
||||
"dhw_boost": "Domestic hot water boost",
|
||||
"heat": "Heat",
|
||||
"hibernate": "Hibernate",
|
||||
"hibernate_to": "Hibernate to",
|
||||
"hibernate": "Setback mode",
|
||||
"hibernate_to": "Away mode",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"timer": "Timer"
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ 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",
|
||||
@@ -118,6 +119,7 @@ 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, date, datetime, time
|
||||
from datetime import UTC, 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(date.today(), r, tzinfo=UTC), # noqa: DTZ011
|
||||
time=datetime.combine(dt_util.now().date(), r, tzinfo=UTC),
|
||||
)
|
||||
for r in add_reminders
|
||||
if r not in existing_reminder_times
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
CONF_DISKS,
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
@@ -28,8 +29,6 @@ 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user