mirror of
https://github.com/home-assistant/core.git
synced 2026-06-12 20:21:40 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adeae40ce1 | |||
| a6d3fb1808 |
+1
-1
@@ -22,4 +22,4 @@ requirements.txt linguist-generated=true
|
||||
requirements_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 merge=ours
|
||||
.github/workflows/*.lock.yml linguist-generated=true
|
||||
|
||||
@@ -14,4 +14,3 @@ updates:
|
||||
ignore:
|
||||
# Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
|
||||
- dependency-name: "github/gh-aw-actions/**"
|
||||
- dependency-name: "github/gh-aw-actions"
|
||||
|
||||
+84
-239
@@ -1,5 +1,5 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"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":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
# | |_| | __ _ ___ _ __ | |_ _ ___
|
||||
@@ -14,7 +14,7 @@
|
||||
# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
|
||||
# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
|
||||
#
|
||||
# This file was automatically generated by gh-aw (v0.79.6). DO NOT EDIT.
|
||||
# This file was automatically generated by gh-aw (v0.74.4). DO NOT EDIT.
|
||||
#
|
||||
# To update this file, edit the corresponding .md file and run:
|
||||
# gh aw compile
|
||||
@@ -36,14 +36,15 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@v0.79.6
|
||||
# - github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6
|
||||
# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4
|
||||
# - ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591
|
||||
# - ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa
|
||||
# - ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c
|
||||
# - 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: "Check requirements (AW)"
|
||||
on:
|
||||
@@ -58,13 +59,15 @@ permissions: {}
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs: pre_activation
|
||||
needs:
|
||||
- extract_pr_number
|
||||
- pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
@@ -73,14 +76,9 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
|
||||
outputs:
|
||||
comment_id: ""
|
||||
comment_repo: ""
|
||||
daily_effective_workflow_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_exceeded == 'true' }}
|
||||
daily_effective_workflow_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_threshold || '' }}
|
||||
daily_effective_workflow_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_total_effective_tokens || '' }}
|
||||
engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
|
||||
lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
|
||||
model: ${{ steps.generate_aw_info.outputs.model }}
|
||||
@@ -92,35 +90,33 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
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 }}
|
||||
safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
|
||||
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.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Generate agentic run info
|
||||
id: generate_aw_info
|
||||
env:
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
|
||||
GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
|
||||
GH_AW_INFO_VERSION: "1.0.60"
|
||||
GH_AW_INFO_AGENT_VERSION: "1.0.60"
|
||||
GH_AW_INFO_CLI_VERSION: "v0.79.6"
|
||||
GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }}
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_AGENT_VERSION: "1.0.48"
|
||||
GH_AW_INFO_CLI_VERSION: "v0.74.4"
|
||||
GH_AW_INFO_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_INFO_EXPERIMENTAL: "false"
|
||||
GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
|
||||
GH_AW_INFO_STAGED: "false"
|
||||
GH_AW_INFO_ALLOWED_DOMAINS: '["python"]'
|
||||
GH_AW_INFO_FIREWALL_ENABLED: "true"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.25.46"
|
||||
GH_AW_INFO_AWMG_VERSION: ""
|
||||
GH_AW_INFO_FIREWALL_TYPE: "squid"
|
||||
GH_AW_COMPILED_STRICT: "true"
|
||||
@@ -131,24 +127,6 @@ jobs:
|
||||
setupGlobals(core, github, context, exec, io, getOctokit);
|
||||
const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
|
||||
await main(core, context);
|
||||
- name: Check daily workflow token guardrail
|
||||
id: daily-effective-workflow-guardrail
|
||||
if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_ID: "check-requirements"
|
||||
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
|
||||
GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
|
||||
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_daily_aic_workflow_guardrail.cjs');
|
||||
await main();
|
||||
- name: Validate COPILOT_GITHUB_TOKEN secret
|
||||
id: validate-secret
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
|
||||
@@ -161,7 +139,6 @@ jobs:
|
||||
sparse-checkout: |
|
||||
.github
|
||||
.agents
|
||||
.antigravity
|
||||
.claude
|
||||
.codex
|
||||
.crush
|
||||
@@ -172,8 +149,8 @@ jobs:
|
||||
fetch-depth: 1
|
||||
- name: Save agent config folders for base branch restoration
|
||||
env:
|
||||
GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi"
|
||||
GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
|
||||
GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi"
|
||||
GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
|
||||
# poutine:ignore untrusted_checkout_exec
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
|
||||
- name: Check workflow lock file
|
||||
@@ -191,7 +168,7 @@ jobs:
|
||||
- name: Check compile-agentic version
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_COMPILED_VERSION: "v0.79.6"
|
||||
GH_AW_COMPILED_VERSION: "v0.74.4"
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@@ -214,20 +191,20 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -256,12 +233,12 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -329,15 +306,12 @@ jobs:
|
||||
include-hidden-files: true
|
||||
path: |
|
||||
/tmp/gh-aw/aw_info.json
|
||||
/tmp/gh-aw/model_multipliers.json
|
||||
/tmp/gh-aw/models.json
|
||||
/tmp/gh-aw/aw-prompts/prompt.txt
|
||||
/tmp/gh-aw/aw-prompts/prompt-template.txt
|
||||
/tmp/gh-aw/aw-prompts/prompt-import-tree.json
|
||||
/tmp/gh-aw/github_rate_limits.jsonl
|
||||
/tmp/gh-aw/base
|
||||
/tmp/gh-aw/.github/agents
|
||||
/tmp/gh-aw/.github/skills
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
@@ -345,15 +319,14 @@ jobs:
|
||||
needs:
|
||||
- activation
|
||||
- extract_pr_number
|
||||
if: needs.activation.outputs.daily_effective_workflow_exceeded != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: "gh-aw-copilot-${{ github.workflow }}"
|
||||
queue: max
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
GH_AW_ASSETS_ALLOWED_EXTS: ""
|
||||
@@ -362,27 +335,24 @@ jobs:
|
||||
GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
GH_AW_WORKFLOW_ID_SANITIZED: checkrequirements
|
||||
outputs:
|
||||
agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
|
||||
ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
|
||||
aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
|
||||
ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
|
||||
agentic_engine_timeout: ${{ steps.detect-copilot-errors.outputs.agentic_engine_timeout || 'false' }}
|
||||
checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
|
||||
effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
|
||||
effective_tokens_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.effective_tokens_rate_limit_error || 'false' }}
|
||||
has_patch: ${{ steps.collect_output.outputs.has_patch }}
|
||||
inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
|
||||
mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
|
||||
inference_access_error: ${{ steps.detect-copilot-errors.outputs.inference_access_error || 'false' }}
|
||||
mcp_policy_error: ${{ steps.detect-copilot-errors.outputs.mcp_policy_error || 'false' }}
|
||||
model: ${{ needs.activation.outputs.model }}
|
||||
model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
|
||||
model_not_supported_error: ${{ steps.detect-copilot-errors.outputs.model_not_supported_error || 'false' }}
|
||||
output: ${{ steps.collect_output.outputs.output }}
|
||||
output_types: ${{ steps.collect_output.outputs.output_types }}
|
||||
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 }}
|
||||
unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -391,8 +361,7 @@ jobs:
|
||||
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.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Set runtime paths
|
||||
id: set-runtime-paths
|
||||
@@ -437,7 +406,7 @@ jobs:
|
||||
- name: Checkout PR branch
|
||||
id: checkout-pr
|
||||
if: |
|
||||
github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
|
||||
github.event.pull_request || github.event.issue.pull_request
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -449,11 +418,11 @@ jobs:
|
||||
const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
|
||||
await main();
|
||||
- name: Install GitHub Copilot CLI
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.48
|
||||
env:
|
||||
GH_HOST: github.com
|
||||
- name: Install AWF binary
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.46
|
||||
- name: Parse integrity filter lists
|
||||
id: parse-guard-vars
|
||||
env:
|
||||
@@ -469,28 +438,24 @@ jobs:
|
||||
- name: Restore agent config folders from base branch
|
||||
if: steps.checkout-pr.outcome == 'success'
|
||||
env:
|
||||
GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi"
|
||||
GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
|
||||
GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi"
|
||||
GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh"
|
||||
- name: Restore inline sub-agents from activation artifact
|
||||
env:
|
||||
GH_AW_SUB_AGENT_DIR: ".github/agents"
|
||||
GH_AW_SUB_AGENT_EXT: ".agent.md"
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh"
|
||||
- name: Restore inline skills from activation artifact
|
||||
env:
|
||||
GH_AW_SKILL_DIR: ".github/skills"
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh"
|
||||
- name: Download container images
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591 ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c
|
||||
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
|
||||
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_f496a449c5dccca1_EOF'
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_f496a449c5dccca1_EOF
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -678,21 +643,21 @@ jobs:
|
||||
* ) DOCKER_SOCK_PATH=/var/run/docker.sock ;;
|
||||
esac
|
||||
DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0')
|
||||
export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.25'
|
||||
export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.9'
|
||||
|
||||
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_f09adf73c5e58a42_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "stdio",
|
||||
"container": "ghcr.io/github/github-mcp-server:v1.1.2",
|
||||
"container": "ghcr.io/github/github-mcp-server:v1.0.4",
|
||||
"env": {
|
||||
"GITHUB_HOST": "\${GITHUB_SERVER_URL}",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
|
||||
"GITHUB_READ_ONLY": "1",
|
||||
"GITHUB_TOOLSETS": "repos,pull_requests"
|
||||
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
|
||||
},
|
||||
"guard-policies": {
|
||||
"allow-only": {
|
||||
@@ -726,7 +691,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_f09adf73c5e58a42_EOF
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -755,48 +720,29 @@ jobs:
|
||||
run: |
|
||||
set -o pipefail
|
||||
printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
|
||||
trap 'rm -f /home/runner/.copilot/settings.json' EXIT
|
||||
mkdir -p /home/runner/.copilot
|
||||
printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > /home/runner/.copilot/settings.json
|
||||
touch /tmp/gh-aw/agent-step-summary.md
|
||||
GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
|
||||
export GH_AW_NODE_BIN
|
||||
export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
|
||||
(umask 177 && touch /tmp/gh-aw/agent-stdio.log)
|
||||
GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}"
|
||||
printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.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\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
|
||||
GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs"
|
||||
cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
|
||||
export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
|
||||
printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.46/awf-config.schema.json","network":{"allowDomains":["*.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"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000,"models":{"auto":["large"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"opus":["copilot/*opus*","anthropic/*opus*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"vision":["copilot/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.25.46"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
|
||||
GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""
|
||||
if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
|
||||
GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"
|
||||
fi
|
||||
GH_AW_TOOL_CACHE_MOUNT=""
|
||||
GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"
|
||||
if [ -d "$GH_AW_TOOL_CACHE" ]; then
|
||||
if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
|
||||
GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
|
||||
fi
|
||||
elif [ -d "/home/runner/work/_tool" ]; then
|
||||
GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro"
|
||||
fi
|
||||
# shellcheck disable=SC1003
|
||||
sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
|
||||
-- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
|
||||
sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
|
||||
-- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
|
||||
env:
|
||||
AWF_REFLECT_ENABLED: 1
|
||||
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
|
||||
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
|
||||
COPILOT_API_KEY: dummy-byok-key-for-offline-mode
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
|
||||
GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
|
||||
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }}
|
||||
GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json
|
||||
GH_AW_PHASE: agent
|
||||
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
|
||||
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
|
||||
GH_AW_TIMEOUT_MINUTES: 20
|
||||
GH_AW_VERSION: v0.79.6
|
||||
GH_AW_VERSION: v0.74.4
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GITHUB_AW: true
|
||||
GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
|
||||
@@ -810,13 +756,12 @@ jobs:
|
||||
GIT_AUTHOR_NAME: github-actions[bot]
|
||||
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
|
||||
GIT_COMMITTER_NAME: github-actions[bot]
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
XDG_CONFIG_HOME: /home/runner
|
||||
- name: Detect agent errors
|
||||
- name: Detect Copilot errors
|
||||
id: detect-copilot-errors
|
||||
if: always()
|
||||
id: detect-agent-errors
|
||||
continue-on-error: true
|
||||
run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
|
||||
run: node "${RUNNER_TEMP}/gh-aw/actions/detect_copilot_errors.cjs"
|
||||
- name: Configure Git credentials
|
||||
env:
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
@@ -997,7 +942,7 @@ jobs:
|
||||
- safe_outputs
|
||||
if: >
|
||||
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
|
||||
needs.activation.outputs.stale_lock_file_failed == 'true' || needs.activation.outputs.daily_effective_workflow_exceeded == 'true')
|
||||
needs.activation.outputs.stale_lock_file_failed == 'true')
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1016,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1025,8 +970,7 @@ jobs:
|
||||
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.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Download agent output artifact
|
||||
id: download-agent-output
|
||||
@@ -1042,40 +986,6 @@ jobs:
|
||||
mkdir -p /tmp/gh-aw/
|
||||
find "/tmp/gh-aw/" -type f -print
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Collect usage artifact files
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection
|
||||
echo "Usage artifact source file status:"
|
||||
for file in /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do
|
||||
[ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file"
|
||||
done
|
||||
[ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true
|
||||
[ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true
|
||||
[ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true
|
||||
[ -f /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
|
||||
[ -f /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
|
||||
[ -f /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
|
||||
[ -f /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
|
||||
[ -f /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
|
||||
[ -f /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
|
||||
[ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl
|
||||
[ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl
|
||||
find /tmp/gh-aw/usage -type f -print | sort
|
||||
- name: Upload usage artifact
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: usage
|
||||
path: |
|
||||
/tmp/gh-aw/usage/aw-info.jsonl
|
||||
/tmp/gh-aw/usage/agent_usage.jsonl
|
||||
/tmp/gh-aw/usage/detection_usage.jsonl
|
||||
/tmp/gh-aw/usage/agent/token_usage.jsonl
|
||||
/tmp/gh-aw/usage/detection/token_usage.jsonl
|
||||
if-no-files-found: ignore
|
||||
- name: Process no-op messages
|
||||
id: noop
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -1083,14 +993,9 @@ jobs:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_NOOP_MAX: "1"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
|
||||
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"
|
||||
GH_AW_AIC: ${{ needs.agent.outputs.aic }}
|
||||
GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
|
||||
GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
|
||||
GH_AW_WORKFLOW_ID: "check-requirements"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1104,7 +1009,6 @@ jobs:
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
|
||||
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 }}
|
||||
@@ -1122,7 +1026,6 @@ jobs:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1137,7 +1040,6 @@ jobs:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1152,7 +1054,6 @@ jobs:
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
|
||||
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"
|
||||
@@ -1161,11 +1062,7 @@ jobs:
|
||||
GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
|
||||
GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
|
||||
GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
|
||||
GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
|
||||
GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
|
||||
GH_AW_AIC: ${{ needs.agent.outputs.aic }}
|
||||
GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
|
||||
GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
|
||||
GH_AW_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.effective_tokens_rate_limit_error || 'false' }}
|
||||
GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
|
||||
GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
|
||||
GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
|
||||
@@ -1173,14 +1070,12 @@ jobs:
|
||||
GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
|
||||
GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
|
||||
GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
|
||||
GH_AW_DAILY_EFFECTIVE_WORKFLOW_EXCEEDED: ${{ needs.activation.outputs.daily_effective_workflow_exceeded }}
|
||||
GH_AW_DAILY_EFFECTIVE_WORKFLOW_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_effective_workflow_total_effective_tokens }}
|
||||
GH_AW_DAILY_EFFECTIVE_WORKFLOW_THRESHOLD: ${{ needs.activation.outputs.daily_effective_workflow_threshold }}
|
||||
GH_AW_GROUP_REPORTS: "false"
|
||||
GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
|
||||
GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
|
||||
GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
|
||||
GH_AW_TIMEOUT_MINUTES: "20"
|
||||
GH_AW_MAX_EFFECTIVE_TOKENS: "25000000"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1199,14 +1094,13 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
aic: ${{ steps.parse_detection_token_usage.outputs.aic }}
|
||||
detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
|
||||
detection_reason: ${{ steps.detection_conclusion.outputs.reason }}
|
||||
detection_success: ${{ steps.detection_conclusion.outputs.success }}
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1215,8 +1109,7 @@ jobs:
|
||||
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.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Download agent output artifact
|
||||
id: download-agent-output
|
||||
@@ -1243,7 +1136,7 @@ jobs:
|
||||
rm -rf /tmp/gh-aw/sandbox/firewall/logs
|
||||
rm -rf /tmp/gh-aw/sandbox/firewall/audit
|
||||
- name: Download container images
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591
|
||||
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
|
||||
- name: Check if detection needed
|
||||
id: detection_guard
|
||||
if: always()
|
||||
@@ -1268,11 +1161,7 @@ jobs:
|
||||
if: always() && steps.detection_guard.outputs.run_detection == 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/gh-aw/threat-detection/aw-prompts
|
||||
rm -f /tmp/gh-aw/agent_usage.json
|
||||
cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true
|
||||
if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then
|
||||
echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context."
|
||||
fi
|
||||
cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true
|
||||
for f in /tmp/gh-aw/aw-*.patch; do
|
||||
[ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
|
||||
@@ -1306,11 +1195,11 @@ jobs:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- name: Install GitHub Copilot CLI
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.48
|
||||
env:
|
||||
GH_HOST: github.com
|
||||
- name: Install AWF binary
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.46
|
||||
- name: Execute GitHub Copilot CLI
|
||||
if: always() && steps.detection_guard.outputs.run_detection == 'true'
|
||||
continue-on-error: true
|
||||
@@ -1320,46 +1209,27 @@ jobs:
|
||||
run: |
|
||||
set -o pipefail
|
||||
printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
|
||||
trap 'rm -f /home/runner/.copilot/settings.json' EXIT
|
||||
mkdir -p /home/runner/.copilot
|
||||
printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > /home/runner/.copilot/settings.json
|
||||
touch /tmp/gh-aw/agent-step-summary.md
|
||||
GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
|
||||
export GH_AW_NODE_BIN
|
||||
export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
|
||||
(umask 177 && touch /tmp/gh-aw/threat-detection/detection.log)
|
||||
GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}"
|
||||
printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
|
||||
GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs"
|
||||
cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
|
||||
export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
|
||||
printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.46/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000},"container":{"imageTag":"0.25.46"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
|
||||
GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""
|
||||
if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
|
||||
GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"
|
||||
fi
|
||||
GH_AW_TOOL_CACHE_MOUNT=""
|
||||
GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"
|
||||
if [ -d "$GH_AW_TOOL_CACHE" ]; then
|
||||
if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
|
||||
GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
|
||||
fi
|
||||
elif [ -d "/home/runner/work/_tool" ]; then
|
||||
GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro"
|
||||
fi
|
||||
# shellcheck disable=SC1003
|
||||
sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
|
||||
-- /bin/bash -c 'set +o histexpand; GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
|
||||
sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
|
||||
-- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
|
||||
env:
|
||||
AWF_REFLECT_ENABLED: 1
|
||||
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
|
||||
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
|
||||
COPILOT_API_KEY: dummy-byok-key-for-offline-mode
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
|
||||
GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
|
||||
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || 'claude-sonnet-4.6' }}
|
||||
GH_AW_PHASE: detection
|
||||
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
|
||||
GH_AW_TIMEOUT_MINUTES: 20
|
||||
GH_AW_VERSION: v0.79.6
|
||||
GH_AW_VERSION: v0.74.4
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GITHUB_AW: true
|
||||
GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
|
||||
@@ -1372,21 +1242,7 @@ jobs:
|
||||
GIT_AUTHOR_NAME: github-actions[bot]
|
||||
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
|
||||
GIT_COMMITTER_NAME: github-actions[bot]
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
XDG_CONFIG_HOME: /home/runner
|
||||
- name: Parse threat detection token usage for step summary
|
||||
id: parse_detection_token_usage
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_TOKEN_USAGE_SUMMARY_TITLE: Threat Detection Token Usage
|
||||
with:
|
||||
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/parse_token_usage.cjs');
|
||||
await main();
|
||||
- name: Upload threat detection log
|
||||
if: always() && steps.detection_guard.outputs.run_detection == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -1428,7 +1284,6 @@ jobs:
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -1440,7 +1295,6 @@ jobs:
|
||||
- name: Configure GH_HOST for enterprise compatibility
|
||||
id: ghes-host-config
|
||||
shell: bash
|
||||
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
|
||||
run: |
|
||||
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
|
||||
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
|
||||
@@ -1471,15 +1325,14 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
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.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Check team membership for workflow
|
||||
id: check_membership
|
||||
@@ -1507,22 +1360,17 @@ jobs:
|
||||
discussions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
|
||||
GH_AW_AIC: ${{ needs.agent.outputs.aic }}
|
||||
GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
|
||||
GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/check-requirements"
|
||||
GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
|
||||
GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
|
||||
GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
|
||||
GH_AW_ENGINE_ID: "copilot"
|
||||
GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
|
||||
GH_AW_ENGINE_VERSION: "1.0.60"
|
||||
GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
|
||||
GH_AW_ENGINE_VERSION: "1.0.48"
|
||||
GH_AW_WORKFLOW_ID: "check-requirements"
|
||||
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
|
||||
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 }}
|
||||
@@ -1535,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1544,8 +1392,7 @@ jobs:
|
||||
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.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Download agent output artifact
|
||||
id: download-agent-output
|
||||
@@ -1564,7 +1411,6 @@ jobs:
|
||||
- name: Configure GH_HOST for enterprise compatibility
|
||||
id: ghes-host-config
|
||||
shell: bash
|
||||
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
|
||||
run: |
|
||||
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
|
||||
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
|
||||
@@ -1576,7 +1422,6 @@ 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_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
|
||||
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 }}
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
network:
|
||||
allowed:
|
||||
@@ -13,7 +14,7 @@ network:
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [repos, pull_requests]
|
||||
toolsets: [default, actions]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
@@ -43,7 +44,7 @@ jobs:
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
@@ -82,289 +83,296 @@ description: >
|
||||
|
||||
# Check requirements (AW)
|
||||
|
||||
You are a code-review assistant for Home Assistant. The deterministic
|
||||
stage already evaluated every check it can and produced an artifact at
|
||||
`/tmp/gh-aw/deterministic/results.json`. Your only job is to resolve any
|
||||
`needs_agent` checks and post the rendered comment.
|
||||
You are a code review assistant for the Home Assistant project. The
|
||||
deterministic stage has already evaluated every check it can on its own
|
||||
and produced an artifact containing the PR number, per-package check
|
||||
results, and a pre-rendered comment with placeholders. **Your only job is
|
||||
to read that artifact, resolve any `needs_agent` checks, and post the
|
||||
final comment.**
|
||||
|
||||
## Step 1 — Read the artifact
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
|
||||
Read the JSON directly for the full schema. Key fields:
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
|
||||
- `pr_number`, `needs_agent` (bool), `packages[]`, `rendered_comment`.
|
||||
- Each `package`: `name`, `old_version` (`null` if new), `new_version`,
|
||||
`repo_url`, `publisher_kind`, `checks` (keyed by check-kind, each
|
||||
with `status` of `pass`/`warn`/`fail`/`needs_agent` and `details`).
|
||||
- `rendered_comment` contains, for each `needs_agent` check, two
|
||||
placeholders to replace:
|
||||
- `{{CHECK_CELL:<pkg>:<kind>}}` → exactly one of `✅`, `☑️`, `⚠️`, `❌`. The
|
||||
**`security`** check kind uses `☑️` instead of `✅` for the success
|
||||
case — see its section below for why.
|
||||
- `{{CHECK_DETAIL:<pkg>:<kind>}}` → `<icon> <one-line explanation>`
|
||||
(the bullet's `- **<label>**:` prefix is already rendered; replace
|
||||
only the placeholder).
|
||||
The JSON has this shape:
|
||||
|
||||
Do not modify other content in `rendered_comment`, do not re-evaluate
|
||||
deterministic checks, do not add or remove packages. If `needs_agent`
|
||||
is `false`, emit `rendered_comment` unchanged.
|
||||
- `pr_number` — the PR being checked. The `add_comment` safe-output is
|
||||
already targeted at this PR (a pre-job extracts `pr_number` from the
|
||||
artifact and the workflow wires it into the safe-output config via
|
||||
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
|
||||
set `item_number` yourself** — just emit `add_comment` with the
|
||||
rendered body.
|
||||
- `needs_agent` — `true` iff any package's check needs resolution.
|
||||
- `packages[]` — one entry per changed package. Each entry has:
|
||||
- `name`, `old_version` (`null` for a newly added package; otherwise the
|
||||
previous pin), `new_version`, `repo_url`, `publisher_kind`.
|
||||
- `checks` — a dict keyed by **check kind** (string). Each value has a
|
||||
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
|
||||
- `rendered_comment` — the final PR comment body, already rendered. For
|
||||
every check whose status is `needs_agent` it contains two placeholders
|
||||
you must replace:
|
||||
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
|
||||
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
|
||||
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
|
||||
in the package's `<details>` block. Replace with
|
||||
`<icon> <one-line explanation>` (the bullet's leading
|
||||
`- **<label>**:` is already rendered — replace only the placeholder).
|
||||
|
||||
You **must not** modify any other content in `rendered_comment`. Do not
|
||||
re-evaluate checks that already have a deterministic status. Do not add
|
||||
or remove packages.
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
|
||||
For each `(package, check_kind)` with `status == "needs_agent"`, find
|
||||
the matching `### Check kind: <check_kind>` section below and follow
|
||||
it. If no section matches, emit a single `add_comment` with:
|
||||
For each `package` in `packages`:
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
For each `(check_kind, result)` in `package.checks` where
|
||||
`result.status == "needs_agent"`:
|
||||
|
||||
❌ Internal error: deterministic artifact contains an unknown check kind
|
||||
(`<check_kind>` on `<pkg>`).
|
||||
```
|
||||
1. Look up `## Check kind: <check_kind>` in the **Check instructions**
|
||||
section below.
|
||||
2. **If no matching section exists**: emit a single `add_comment` whose
|
||||
body is:
|
||||
|
||||
Then stop. Do not improvise a verdict.
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
❌ Internal error: the deterministic artifact contains a check kind
|
||||
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
|
||||
instructions for. Update `.github/workflows/check-requirements.md`
|
||||
to add a matching `## Check kind: <check_kind>` section, or remove
|
||||
the kind from the deterministic stage.
|
||||
```
|
||||
|
||||
Then stop. **Do not improvise** a verdict for an unknown check kind.
|
||||
3. Otherwise, follow the instructions in that section. They tell you
|
||||
which icon (✅/⚠️/❌) and one-line explanation to produce.
|
||||
|
||||
## Step 3 — Post the comment
|
||||
|
||||
Replace every placeholder with the resolved value and emit
|
||||
`rendered_comment` via `add_comment`. Preserve the leading
|
||||
`<!-- requirements-check -->` marker. The PR target is already wired;
|
||||
do not pass `item_number`.
|
||||
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
|
||||
in `rendered_comment` with the resolved value.
|
||||
2. Emit the resulting markdown using `add_comment` — set `body` to the
|
||||
merged `rendered_comment` verbatim (the leading
|
||||
`<!-- requirements-check -->` marker must be preserved). The PR
|
||||
target is already set by the workflow; do not pass `item_number`.
|
||||
|
||||
If the artifact's top-level `needs_agent` is `false` (no checks need
|
||||
you), emit `rendered_comment` unchanged.
|
||||
|
||||
## Check instructions
|
||||
|
||||
### Check kind: `repo_public`
|
||||
|
||||
`web-fetch` GET `package.repo_url`.
|
||||
- 200 + public repo page → ✅ `<repo_url> is publicly accessible.`
|
||||
- 4xx/5xx or login redirect → ❌ `Source repository at <repo_url> is
|
||||
not publicly accessible. Home Assistant requires dependencies to
|
||||
have publicly available source code.`
|
||||
- Otherwise → ⚠️ with a one-line description.
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
|
||||
If ❌, also mark this package's `release_pipeline` and `async_blocking`
|
||||
cells/details as `—` and explain `Skipped because the source
|
||||
repository is not publicly accessible.`.
|
||||
1. Read `package.repo_url`.
|
||||
2. Use the `web-fetch` tool to GET that URL.
|
||||
3. Decide the verdict:
|
||||
- HTTP 200, returns a public repository page → ✅
|
||||
`<repo_url> is publicly accessible.`
|
||||
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
|
||||
❌ `Source repository at <repo_url> is not publicly accessible.
|
||||
Home Assistant requires all dependencies to have publicly available
|
||||
source code.`
|
||||
- Any other inconclusive result → ⚠️ with a one-line description.
|
||||
|
||||
If `repo_public` resolves to ❌ for a package, **also** mark that
|
||||
package's `release_pipeline` and `async_blocking` cells/details as `—`
|
||||
(em dash) and explain `Skipped because the source repository is not
|
||||
publicly accessible.` — neither check can be performed without a public
|
||||
repo.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
Fetch the PR body via the `pull_requests` MCP using `pr_number`. Extract URLs.
|
||||
Verify the PR description contains the right link for the change.
|
||||
|
||||
- **New package** (`old_version == null`): body must contain a URL
|
||||
pointing at `repo_url`'s `owner/repo` on the same host (any
|
||||
sub-path OK). PyPI is not sufficient.
|
||||
- ✅ if present; otherwise ❌ `PR description must link to the
|
||||
source repository at <repo_url>. A PyPI page link is not
|
||||
sufficient.`
|
||||
- **Version bump**: body must contain a URL on the same host as
|
||||
`repo_url` that mentions **both** `old_version` and `new_version`
|
||||
(compare URL, changelog, release page).
|
||||
- ✅ if present and versions match; otherwise ❌ `PR description
|
||||
should link to a changelog or compare URL on <repo_url> that
|
||||
mentions both <old_version> and <new_version>.`
|
||||
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
|
||||
field from the artifact.
|
||||
2. Extract all URLs from the body.
|
||||
3. For a **new package** (`package.old_version` is `null`):
|
||||
- The PR body must contain a URL that points at `package.repo_url`
|
||||
(any sub-path of the same `owner/repo` on the same host is
|
||||
acceptable). A PyPI link is **not** sufficient.
|
||||
- ✅ if such a URL is present.
|
||||
- ❌ otherwise:
|
||||
`PR description must link to the source repository at <repo_url>.
|
||||
A PyPI page link is not sufficient.`
|
||||
4. For a **version bump** (`package.old_version` is not `null`):
|
||||
- The PR body must contain a URL on the same host as
|
||||
`package.repo_url` that references **both** `package.old_version`
|
||||
and `package.new_version` (e.g. a GitHub compare URL
|
||||
`compare/vX...vY`, a release / changelog URL containing both
|
||||
versions, etc.).
|
||||
- ✅ if such a URL is present and the versions match the actual bump.
|
||||
- ❌ otherwise:
|
||||
`PR description should link to a changelog or compare URL on
|
||||
<repo_url> that mentions both <old_version> and <new_version>.`
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
|
||||
Inspect the upstream's publish-to-PyPI CI. Host-specific lookup, same
|
||||
rubric:
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
|
||||
1. Locate the publish workflow / job (name or filename contains
|
||||
`release`, `publish`, `pypi`, or `deploy`).
|
||||
- GitHub: list `.github/workflows/` via the `repos` MCP, pick the
|
||||
promising file by name, fetch its contents.
|
||||
- GitLab: fetch `.gitlab-ci.yml` from the default ref via
|
||||
`https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
- Other hosts: `web-fetch` an obvious CI config
|
||||
(`.circleci/config.yml`, `bitbucket-pipelines.yml`, etc.).
|
||||
2. Apply this rubric:
|
||||
- **Trigger**: tag push / `release: published` / protected branch —
|
||||
not solely manual dispatch without an environment guard.
|
||||
- **Credentials**: OIDC (`id-token: write` +
|
||||
`pypa/gh-action-pypi-publish` or equivalent) preferred; static
|
||||
`PYPI_TOKEN` from a CI secret acceptable for a bump.
|
||||
- **No bypass**: no ungated `twine upload` / `pip upload`.
|
||||
3. Verdict:
|
||||
- ✅ — OIDC + sane triggers + no bypass.
|
||||
- ⚠️ — static token on a bump, details unclear, or
|
||||
non-GitHub/GitLab host with limited CI visibility.
|
||||
- ❌ — static token on a new package, or manual-only triggers
|
||||
without environment protection.
|
||||
For each package needing inspection, determine the source repository
|
||||
host from `package.repo_url`, then apply the corresponding checklist.
|
||||
|
||||
#### GitHub repositories (`github.com`)
|
||||
|
||||
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
|
||||
2. Identify any workflow whose name or filename suggests publishing to
|
||||
PyPI (`release`, `publish`, `pypi`, or `deploy`).
|
||||
3. Fetch the workflow file and check:
|
||||
- **Trigger sanity**: triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job —
|
||||
**not** solely `workflow_dispatch` with no environment-protection
|
||||
guard.
|
||||
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
|
||||
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
|
||||
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
|
||||
- **No manual upload bypass**: no ungated `twine upload` or
|
||||
`pip upload`.
|
||||
4. Verdict:
|
||||
- ✅ if OIDC + sane triggers + no bypass.
|
||||
- ⚠️ if static token but version bump, or details unclear.
|
||||
- ❌ if static token on a new package, or only-manual triggers with
|
||||
no environment protection.
|
||||
|
||||
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
|
||||
2. Fetch `.gitlab-ci.yml` via
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
3. Apply the same conceptual checks: tag-only / protected-branch
|
||||
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
|
||||
ungated `twine upload`. Same verdict rules as GitHub.
|
||||
|
||||
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
|
||||
|
||||
1. Use `web-fetch` to retrieve any visible CI configuration
|
||||
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
|
||||
`bitbucket-pipelines.yml`, `.builds/*.yml`).
|
||||
2. Apply the conceptual checks: automated triggers, CI-injected
|
||||
credentials, no manual `twine upload`.
|
||||
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
|
||||
inspected; hosting provider is not GitHub or GitLab.`
|
||||
|
||||
### Check kind: `async_blocking`
|
||||
|
||||
Verify the dependency does not call blocking APIs inside `async def`
|
||||
bodies. Home Assistant runs on a single asyncio loop, so blocking
|
||||
calls from the async surface stall the whole loop. A purely sync
|
||||
library is fine — integrations wrap its calls in an executor.
|
||||
Verify whether the dependency performs blocking I/O inside async code
|
||||
paths. Home Assistant runs on a single asyncio event loop, so a library
|
||||
that exposes an `async` surface must not call blocking APIs from inside
|
||||
its `async def` functions — that stalls the whole loop. A purely sync
|
||||
library is fine: Home Assistant integrations are expected to wrap such
|
||||
calls in an executor.
|
||||
|
||||
**Mode** (decided by `old_version`):
|
||||
- `null` → new package: review the entire current source tree.
|
||||
- string → version bump: review only the diff between the two tags.
|
||||
Blocking calls already present in `old_version` are not regressions.
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
|
||||
**Step 1 — async surface?**
|
||||
- `old_version` is `null` → **new package**: review the *entire current
|
||||
source tree*. Nothing about this dependency has been vetted before.
|
||||
- `old_version` is a string → **version bump**: review only the *diff
|
||||
between `old_version` and `new_version`*. The previous version was
|
||||
already accepted, so blocking calls that were present in
|
||||
`old_version` are not regressions; report only what `new_version`
|
||||
introduces.
|
||||
|
||||
Fetch `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` at the
|
||||
tag matching `new_version` (try `v{version}`, `{version}`,
|
||||
`release-{version}` — at most three attempts). Use the `repos` MCP for
|
||||
github.com, `web-fetch` otherwise.
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
|
||||
If sync-only (no `async def` in public modules; no
|
||||
asyncio/aiohttp/httpx/anyio in deps; no `Framework :: AsyncIO`
|
||||
classifier) → ✅ `Sync-only library; Home Assistant integrations must
|
||||
wrap calls in an executor.` (Same verdict for both modes.)
|
||||
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
|
||||
(other hosts) on `package.repo_url`. Always inspect the tag /
|
||||
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
|
||||
|
||||
**Step 2 — review the surface**
|
||||
- Locate the top-level package directory (usually named after the
|
||||
import name, often equal or close to `package.name`).
|
||||
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
|
||||
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
|
||||
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
|
||||
example in the README).
|
||||
- Grep the package source for `async def`. A handful of `async def`
|
||||
entries in the public modules is enough to treat the library as
|
||||
having an async surface.
|
||||
|
||||
- New package: grep public modules for `async def`, inspect each
|
||||
async body and transitive helpers.
|
||||
- Bump: fetch the compare diff
|
||||
(`/repos/{owner}/{repo}/compare/{old}...{new}` on GitHub, equivalent
|
||||
on GitLab/other hosts). Only flag patterns on **added** lines that
|
||||
are inside or reachable from `async def`. If no tag format resolves,
|
||||
fall back to a full review and note that the diff was unavailable.
|
||||
If the library is **sync-only** (no `async def` in its public modules
|
||||
and no async framework dependency) → ✅
|
||||
`Sync-only library; Home Assistant integrations must wrap calls in an
|
||||
executor.` *This verdict is the same in both modes.*
|
||||
|
||||
**Blocking patterns to flag inside `async def`:**
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(`, `pycurl`.
|
||||
- `time.sleep(` (use `await asyncio.sleep(`).
|
||||
- Sync sockets/SSL: bare `socket.socket` I/O, `ssl.wrap_socket`,
|
||||
Inspect **every `async def` in the public modules** for blocking
|
||||
patterns. Walk transitively into helpers the async functions call.
|
||||
|
||||
#### Step 2b — Mode: version bump (`old_version` is a string)
|
||||
|
||||
Fetch the diff between the two tags and review **only changed lines**:
|
||||
|
||||
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
|
||||
the `github` MCP tool, or
|
||||
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
|
||||
via `web-fetch`. Try the common tag formats in order until one
|
||||
resolves: `v{version}`, `{version}`, `release-{version}`.
|
||||
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
|
||||
- Other hosts: use the project's equivalent compare URL via
|
||||
`web-fetch`.
|
||||
|
||||
If neither tag format resolves on the host, fall back to a full review
|
||||
(Step 2a) and mention in the detail that the diff was unavailable.
|
||||
|
||||
When reviewing the diff, only flag blocking patterns that appear in
|
||||
**added lines** *inside or reachable from* an `async def`. A blocking
|
||||
call that existed in `old_version` and is unchanged is not a regression
|
||||
for this bump.
|
||||
|
||||
#### Step 3 — Blocking patterns to look for
|
||||
|
||||
In both modes, the patterns to flag inside `async def` bodies are:
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
|
||||
`AsyncClient`), `pycurl`.
|
||||
- `time.sleep(` (must be `await asyncio.sleep(`).
|
||||
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
|
||||
blocking `select.select`.
|
||||
- File I/O on the request path: `open(` /
|
||||
`pathlib.Path.read_*` / `.write_*` for non-trivial sizes (small
|
||||
one-shot reads during import are OK).
|
||||
- Sync DB drivers: `sqlite3`, `psycopg2`, `pymysql`, sync `pymongo` /
|
||||
`redis.Redis`.
|
||||
- `subprocess.run` / `subprocess.call` / `os.system`.
|
||||
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
|
||||
non-trivial sizes (small one-shot reads during import are
|
||||
acceptable; reads/writes on the request path are not — prefer
|
||||
`aiofiles` / executor).
|
||||
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
|
||||
`pymongo` (sync client), `redis.Redis` (sync client).
|
||||
- `subprocess.run` / `subprocess.call` / `os.system` (must be
|
||||
`asyncio.create_subprocess_*`).
|
||||
|
||||
Calls dispatched to an executor (`run_in_executor`,
|
||||
`asyncio.to_thread`, `anyio.to_thread.run_sync`) do **not** count as
|
||||
blocking.
|
||||
A call that is clearly dispatched to an executor
|
||||
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
|
||||
does NOT count as blocking.
|
||||
|
||||
**Verdict:**
|
||||
#### Step 4 — Verdict
|
||||
|
||||
- ✅ — no offending pattern. Bumps: phrase as `No new blocking calls
|
||||
introduced in {old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking only in sync helpers the async API never calls, or
|
||||
clearly off the hot path (e.g. one-shot pre-loop setup). Cite at
|
||||
least one `<file>:<line>` and say why it's not hot.
|
||||
- ❌ — blocking call reachable from a public `async def` on the
|
||||
request/polling path (bump: introduced or moved onto the hot path
|
||||
by this version). Cite the offending `<file>:<line>` as a clickable
|
||||
link on the repo host.
|
||||
|
||||
### Check kind: `security`
|
||||
|
||||
**Baseline** scan of the upstream source for obvious supply-chain red
|
||||
flags — a cheap first pass, **not** a security review or malware audit.
|
||||
A clean result means "nothing obvious stood out", not "this package is
|
||||
safe". The success icon is `☑️` — **never** `✅` — so a passing scan is
|
||||
not read as an endorsement.
|
||||
|
||||
If `repo_public` resolves to ❌ for the same package, mark `security`'s
|
||||
cell and detail as `—` and explain `Skipped because the source
|
||||
repository is not publicly accessible.` — the source cannot be fetched.
|
||||
|
||||
**Step 1 — Fetch a representative slice**
|
||||
|
||||
Locate the source from `package.repo_url`.
|
||||
|
||||
- GitHub: resolve the default branch (`GET /repos/{owner}/{repo}`), list
|
||||
the tree (`GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1`),
|
||||
find the module dir (`{name}/` or `src/{name}/`, normalising `-` ↔ `_`).
|
||||
- GitLab: equivalent REST calls. Other hosts: `web-fetch` raw file URLs.
|
||||
|
||||
Fetch the **raw contents** of `setup.py` (install-time code runs on every
|
||||
consumer), `pyproject.toml` (`[build-system]` / custom backend), the
|
||||
package's `__init__.py`, and co — prioritising `entry_points` targets, plus any name suggesting
|
||||
bootstrap / loader / self-update (`update*.py`, `loader*.py`,
|
||||
`bootstrap*.py`, `_native.py`, `_post_install*.py`, …).
|
||||
|
||||
If the tree is too large for the API budget, inspect at least `setup.py`,
|
||||
`pyproject.toml`, and `__init__.py`, then return ⚠️ noting the partial scan.
|
||||
|
||||
**Step 2 — Patterns to flag**
|
||||
|
||||
Reason from principles, not a fixed checklist: for each file ask *would a
|
||||
well-behaved library doing what this package's PyPI description claims
|
||||
need to do this?* If "no" or "unclear", record a finding. The categories
|
||||
describe the **shape** of concerning behavior; the named APIs, filenames,
|
||||
and keys are illustrative — treat any equivalent construct (including ones
|
||||
that did not exist when this was written) the same way.
|
||||
|
||||
For every finding include the file path, line number, a snippet
|
||||
(≤ 120 chars), a permalink
|
||||
(`https://github.com/{owner}/{repo}/blob/{sha}/{path}#L{line}` or the
|
||||
GitLab equivalent), and one sentence on why it is out of scope.
|
||||
|
||||
1. **Reaches into Home Assistant internals.** A library should touch HA
|
||||
only through its documented Python API — never the `config_dir`
|
||||
filesystem or internal auth / session state. Flag code that opens,
|
||||
reads, writes, or resolves paths to artifacts it does not own
|
||||
(top-level YAML it did not create, anything under `.storage/`, other
|
||||
integrations' files) or reads tokens / refresh tokens / auth providers
|
||||
(e.g. `secrets.yaml`, `.storage/auth*`, `hass.auth`). The principle is
|
||||
*out-of-scope access*, not a static list of names.
|
||||
2. **Network input flows into an execution sink (download-and-execute).**
|
||||
Flag any data-flow from a network response body (any HTTP / WebSocket /
|
||||
raw-socket client, sync or async) to an execution sink: `exec`, `eval`,
|
||||
`compile`, `marshal.loads`, `pickle.loads`, `types.FunctionType`,
|
||||
`importlib.util.spec_from_loader`, `subprocess.*`, `os.system`, shell
|
||||
pipelines (`curl … | sh`), or a file later imported / executed — plus
|
||||
package-manager calls (`pip install` / `download`) with args resolved
|
||||
from network responses at runtime.
|
||||
3. **Build / install-time code is non-deterministic or non-local.**
|
||||
`setup.py`, `setup.cfg` `cmdclass`, custom PEP 517 backends, and other
|
||||
build hooks must only compile and copy files shipped in the sdist. Flag
|
||||
build-stage code that opens a socket, shells out, writes outside the
|
||||
build / install tree, or pulls a build backend not on PyPI (Git URL /
|
||||
local path).
|
||||
4. **Reads secrets and combines them with an egress path.** The shape is
|
||||
*secret-source → outbound-channel*. Flag code that reads credential
|
||||
material (token-like env vars, credential files under the user's home,
|
||||
OS keychain APIs, browser-profile dirs, HA token stores) **and** in the
|
||||
same path sends it to a destination the package needn't talk to.
|
||||
Reading or sending alone is not enough — the *combination* is the signal.
|
||||
5. **Hides what it does.** Flag opaque data flowing into an execution
|
||||
sink: large encoded / compressed / hex strings (`base64`, `codecs`,
|
||||
`zlib`, `lzma`, `bytes.fromhex`, or any equivalent) passed to `exec` /
|
||||
`eval` / `compile` / `__import__`; identifiers assembled at runtime
|
||||
then imported; or any construct whose evident purpose is to make the
|
||||
behavior unreadable.
|
||||
6. **Hard-coded network destination off-purpose.** Flag outbound URLs or
|
||||
hosts absent from the package's PyPI `project_urls` with no obvious
|
||||
connection to its function — short-link / paste services, ephemeral
|
||||
tunnels, raw IPs, non-default ports against unknown hosts — and any
|
||||
network call at module top-level / `__init__.py` (runs on import for
|
||||
every consumer).
|
||||
|
||||
A clearly out-of-scope behavior that fits none of the above: flag under
|
||||
the closest category and explain. The categories guide reasoning, not bound it.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Aggregate the findings into one of:
|
||||
|
||||
- `☑️ Baseline scan found nothing obvious in <list of inspected files>.
|
||||
This is not a security review — only the cheap checks were run.`
|
||||
Use `☑️` (**not** `✅`) so a passing scan is not read as an endorsement.
|
||||
- `⚠️ <one-line summary>` — patterns with plausible legitimate uses;
|
||||
include path / line / snippet / permalink per match for the reviewer.
|
||||
- `❌ <one-line summary>` — patterns with no legitimate explanation
|
||||
(install-time network execution, decode-and-exec of opaque blobs, reads
|
||||
of `secrets.yaml` / `.storage/auth*`, token exfiltration to an external
|
||||
host); same detail.
|
||||
|
||||
Be precise. False positives are expected — when in doubt prefer `⚠️` with
|
||||
context over `❌`. This check is informational and never blocks the
|
||||
workflow on its own; a human reviewer decides whether to merge.
|
||||
- ✅ — no offending blocking pattern in the surface being reviewed
|
||||
(whole tree for a new package, added lines for a bump). For a bump,
|
||||
phrase the detail as `No new blocking calls introduced in
|
||||
{old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking calls exist only in sync helpers that the async API
|
||||
does not call, or only on a clearly non-hot path (e.g. one-shot
|
||||
setup before the event loop is running). Cite at least one
|
||||
`<file>:<line>` and explain why it is not on the hot path.
|
||||
- ❌ — a blocking call is reachable from an `async def` that is part
|
||||
of the public API on the request / polling path (for a bump: the
|
||||
call was introduced or moved onto the hot path by this version).
|
||||
Cite the offending `<file>:<line>` as a clickable link on the repo
|
||||
host so the contributor can jump to it.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive; reference the inspected file by URL when useful.
|
||||
- Comment dedup is handled by gh-aw's `add_comment` safe-output via
|
||||
the `<!-- requirements-check -->` marker.
|
||||
- If `/tmp/gh-aw/deterministic/results.json` is missing (upstream
|
||||
cancelled/failed), emit nothing — the post-step verification is
|
||||
gated and won't complain.
|
||||
- Be constructive and helpful. Reference the inspected workflow / CI
|
||||
file by URL where useful so the contributor can fix the issue.
|
||||
- The dedup of the requirements-check comment is handled by gh-aw's
|
||||
`add_comment` safe-output via the `<!-- requirements-check -->`
|
||||
marker on the first line of `rendered_comment`.
|
||||
- If the deterministic workflow concluded with a non-success status,
|
||||
this workflow's `if:` guard on `Download deterministic-results
|
||||
artifact` skipped the download. If you find no file at
|
||||
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
|
||||
verification is also gated and will not complain.
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.16
|
||||
rev: v0.15.15
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
Generated
-4
@@ -947,8 +947,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/kiosker/ @Claeysson
|
||||
/homeassistant/components/kitchen_sink/ @home-assistant/core
|
||||
/tests/components/kitchen_sink/ @home-assistant/core
|
||||
/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer
|
||||
/tests/components/klik_aan_klik_uit/ @Phunkafizer
|
||||
/homeassistant/components/kmtronic/ @dgomes
|
||||
/tests/components/kmtronic/ @dgomes
|
||||
/homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
|
||||
@@ -1086,8 +1084,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/mediaroom/ @dgomes
|
||||
/homeassistant/components/melcloud/ @erwindouna
|
||||
/tests/components/melcloud/ @erwindouna
|
||||
/homeassistant/components/melcloud_home/ @erwindouna
|
||||
/tests/components/melcloud_home/ @erwindouna
|
||||
/homeassistant/components/melissa/ @kennedyshead
|
||||
/tests/components/melissa/ @kennedyshead
|
||||
/homeassistant/components/melnor/ @vanstinator
|
||||
|
||||
@@ -12,18 +12,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
"access_token",
|
||||
"adp_token",
|
||||
"device_private_key",
|
||||
"refresh_token",
|
||||
"store_authentication_cookie",
|
||||
"title",
|
||||
"website_cookies",
|
||||
}
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.3"]
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Coordinator for the Anthropic integration."""
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import anthropic
|
||||
|
||||
@@ -19,12 +20,15 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
|
||||
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
|
||||
|
||||
|
||||
_model_short_form = re.compile(r"[^\d]-\d$")
|
||||
|
||||
|
||||
@callback
|
||||
def model_alias(model_id: str) -> str:
|
||||
"""Resolve alias from versioned model name."""
|
||||
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
|
||||
model_id = model_id[:-9]
|
||||
if model_id.endswith("-4"):
|
||||
if _model_short_form.search(model_id):
|
||||
return model_id + "-0"
|
||||
return model_id
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["anthropic==0.108.0"]
|
||||
"requirements": ["anthropic==0.96.0"]
|
||||
}
|
||||
|
||||
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
]
|
||||
self._model_list_cache[entry.entry_id] = model_list
|
||||
|
||||
family = (
|
||||
model.removeprefix("claude-")
|
||||
.removesuffix("-preview")
|
||||
.translate(str.maketrans("", "", "0123456789-."))
|
||||
or "haiku"
|
||||
)
|
||||
if "opus" in model:
|
||||
family = "claude-opus"
|
||||
elif "sonnet" in model:
|
||||
family = "claude-sonnet"
|
||||
else:
|
||||
family = "claude-haiku"
|
||||
|
||||
suggested_model = next(
|
||||
(
|
||||
model_option["value"]
|
||||
for model_option in sorted(
|
||||
(m for m in model_list if f"claude-{family}" in m["value"]),
|
||||
(m for m in model_list if family in m["value"]),
|
||||
key=lambda x: x["value"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@@ -8,11 +8,7 @@ from aiohttp import ClientResponseError
|
||||
from pyaqvify import AqvifyAPI, AqvifyAuthException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -53,11 +49,6 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(account_data.account_id)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Aqvify", data=user_input)
|
||||
|
||||
@@ -105,9 +96,3 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User initiated reconfiguration."""
|
||||
return await self.async_step_user()
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The entered API key corresponds to a different account."
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -31,7 +31,7 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -144,9 +144,7 @@ def _sensor_device_info_to_hass(
|
||||
adv: Aranet4Advertisement,
|
||||
) -> DeviceInfo:
|
||||
"""Convert a sensor device info to hass device info."""
|
||||
hass_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, adv.device.address)}
|
||||
)
|
||||
hass_device_info = DeviceInfo({})
|
||||
if adv.readings and adv.readings.name:
|
||||
hass_device_info[ATTR_NAME] = adv.readings.name
|
||||
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.7.0"]
|
||||
"requirements": ["hassil==3.6.0"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["mozart-api==6.2.0.44.0"],
|
||||
"requirements": ["mozart-api==5.3.1.108.2"],
|
||||
"zeroconf": ["_bangolufsen._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ BINARY_SENSOR_TYPES = (
|
||||
key="open",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="input",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -59,8 +56,6 @@ class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEn
|
||||
"""Initialize a BleBox binary sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = description
|
||||
if feature.name:
|
||||
self._attr_name = feature.name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -41,8 +41,6 @@ async def async_setup_entry(
|
||||
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
|
||||
"""Representation of BleBox buttons."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
|
||||
@@ -51,7 +51,6 @@ async def async_setup_entry(
|
||||
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
|
||||
"""Representation of a BleBox climate feature (saunaBox)."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
|
||||
@@ -24,13 +24,3 @@ OPEN_STATUS: dict[int, str] = {
|
||||
|
||||
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
|
||||
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
|
||||
|
||||
CO2_LEVEL: dict[int, str] = {
|
||||
0: "excellent",
|
||||
1: "good",
|
||||
2: "acceptable",
|
||||
3: "medium",
|
||||
4: "poor",
|
||||
5: "unhealthy",
|
||||
6: "hazardous",
|
||||
}
|
||||
|
||||
@@ -74,8 +74,6 @@ async def async_setup_entry(
|
||||
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Representation of a BleBox cover feature."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
|
||||
) -> None:
|
||||
@@ -92,10 +90,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
|
||||
if feature.has_tilt:
|
||||
self._attr_supported_features |= (
|
||||
CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
|
||||
CoverEntityFeature.SET_TILT_POSITION
|
||||
| CoverEntityFeature.OPEN_TILT
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
)
|
||||
if feature.is_calibrated:
|
||||
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
|
||||
|
||||
if feature.tilt_only:
|
||||
self._attr_supported_features &= ~(
|
||||
|
||||
@@ -12,12 +12,11 @@ from .coordinator import BleBoxCoordinator
|
||||
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
"""Implements a common class for entities representing a BleBox feature."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
|
||||
"""Initialize a BleBox entity."""
|
||||
super().__init__(coordinator)
|
||||
self._feature = feature
|
||||
self._attr_name = feature.full_name
|
||||
self._attr_unique_id = feature.unique_id
|
||||
product = feature.product
|
||||
self._attr_device_info = DeviceInfo(
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"co2_level": {
|
||||
"default": "mdi:molecule-co2"
|
||||
},
|
||||
"open_status": {
|
||||
"default": "mdi:window-open"
|
||||
},
|
||||
|
||||
@@ -71,11 +71,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
super().__init__(coordinator, feature)
|
||||
if feature.effect_list:
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
if feature.index is not None:
|
||||
self._attr_translation_key = "channel"
|
||||
self._attr_translation_placeholders = {"index": str(feature.index + 1)}
|
||||
else:
|
||||
self._attr_name = None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["blebox_uniapi"],
|
||||
"requirements": ["blebox-uniapi==2.5.5"],
|
||||
"requirements": ["blebox-uniapi==2.5.4"],
|
||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -15,7 +14,6 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
UnitOfApparentPower,
|
||||
@@ -33,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import CO2_LEVEL, OPEN_STATUS
|
||||
from .const import OPEN_STATUS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
@@ -68,7 +66,6 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="temperature",
|
||||
translation_key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -99,56 +96,48 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="forwardActiveEnergy",
|
||||
translation_key="forward_active_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="reverseActiveEnergy",
|
||||
translation_key="reverse_active_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="reactivePower",
|
||||
translation_key="reactive_power",
|
||||
device_class=SensorDeviceClass.REACTIVE_POWER,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="activePower",
|
||||
translation_key="active_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="apparentPower",
|
||||
translation_key="apparent_power",
|
||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="voltage",
|
||||
translation_key="voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="current",
|
||||
translation_key="current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="frequency",
|
||||
translation_key="frequency",
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -160,19 +149,6 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
options=list(OPEN_STATUS.values()),
|
||||
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="co2Definition",
|
||||
translation_key="co2_level",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CO2_LEVEL.values()),
|
||||
value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -182,20 +158,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
features = coordinator.box.features.get("sensors", [])
|
||||
counts = Counter(f.device_class for f in features)
|
||||
entities = [
|
||||
BleBoxSensorEntity(
|
||||
coordinator,
|
||||
feature,
|
||||
description,
|
||||
feature.index
|
||||
if counts[feature.device_class] > 1 and feature.index
|
||||
else None,
|
||||
)
|
||||
for feature in features
|
||||
BleBoxSensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("sensors", [])
|
||||
for description in SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
@@ -212,16 +178,10 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: blebox_uniapi.sensor.BaseSensor,
|
||||
description: BleBoxSensorEntityDescription,
|
||||
index: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize a BleBox sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = description
|
||||
if feature.name:
|
||||
self._attr_name = feature.name
|
||||
elif index is not None and description.translation_key:
|
||||
self._attr_translation_key = f"{description.translation_key}_n"
|
||||
self._attr_translation_placeholders = {"index": str(index)}
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
|
||||
@@ -30,44 +30,14 @@
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address of your BleBox device.",
|
||||
"password": "The password for your BleBox device.",
|
||||
"port": "The port of your BleBox device.",
|
||||
"username": "The username for your BleBox device."
|
||||
},
|
||||
"description": "Set up your BleBox to integrate with Home Assistant.",
|
||||
"title": "Set up your BleBox device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"light": { "channel": { "name": "Channel {index}" } },
|
||||
"sensor": {
|
||||
"active_power": { "name": "Active power" },
|
||||
"active_power_n": { "name": "Active power {index}" },
|
||||
"apparent_power": { "name": "Apparent power" },
|
||||
"apparent_power_n": { "name": "Apparent power {index}" },
|
||||
"co2_level": {
|
||||
"name": "Carbon dioxide level",
|
||||
"state": {
|
||||
"acceptable": "Acceptable",
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"hazardous": "Hazardous",
|
||||
"medium": "Medium",
|
||||
"poor": "Poor",
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
},
|
||||
"current": { "name": "Current" },
|
||||
"current_n": { "name": "Current {index}" },
|
||||
"forward_active_energy": { "name": "Forward active energy" },
|
||||
"forward_active_energy_n": { "name": "Forward active energy {index}" },
|
||||
"frequency": { "name": "Frequency" },
|
||||
"frequency_n": { "name": "Frequency {index}" },
|
||||
"open_status": {
|
||||
"name": "Open status",
|
||||
"state": {
|
||||
"ajar": "Ajar",
|
||||
"closed": "[%key:common::state::closed%]",
|
||||
@@ -75,16 +45,7 @@
|
||||
"open": "[%key:common::state::open%]",
|
||||
"unclosed_or_unlocked": "Unclosed or unlocked"
|
||||
}
|
||||
},
|
||||
"power_consumption": { "name": "Energy last hour" },
|
||||
"reactive_power": { "name": "Reactive power" },
|
||||
"reactive_power_n": { "name": "Reactive power {index}" },
|
||||
"reverse_active_energy": { "name": "Reverse active energy" },
|
||||
"reverse_active_energy_n": { "name": "Reverse active energy {index}" },
|
||||
"temperature": { "name": "Temperature" },
|
||||
"temperature_n": { "name": "Temperature {index}" },
|
||||
"voltage": { "name": "Voltage" },
|
||||
"voltage_n": { "name": "Voltage {index}" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
@@ -35,16 +34,6 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
|
||||
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.switch.Switch
|
||||
) -> None:
|
||||
"""Initialize a BleBox switch feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
if feature.name:
|
||||
self._attr_name = feature.name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether switch is on."""
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""The Brands integration."""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Container, Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, override
|
||||
|
||||
from aiohttp import ClientError, hdrs, web
|
||||
from aiohttp import ClientError, web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback, valid_domain
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
|
||||
class _BrandsBaseView(HomeAssistantView):
|
||||
"""Base view for serving brand images."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the view."""
|
||||
self._hass = hass
|
||||
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
|
||||
|
||||
def _authenticate(self, request: web.Request) -> None:
|
||||
"""Authenticate the request using Bearer token or query token."""
|
||||
access_tokens: deque[str] = self._hass.data[DOMAIN]
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
raise web.HTTPForbidden
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
return self._hass.data[DOMAIN]
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
self,
|
||||
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for an integration brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for a hardware brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not CATEGORY_RE.match(category):
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
# Hardware images have dynamic names like "manufacturer_model.png"
|
||||
|
||||
@@ -125,7 +125,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update an item in the To-do list.
|
||||
"""Update an item to the To-do list.
|
||||
|
||||
Bring has an internal 'recent' list which we want to use instead of a todo list
|
||||
status, therefore completed todo list items are matched to the recent list and
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.23.4"]
|
||||
"requirements": ["bthome-ble==3.23.2"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -12,16 +12,16 @@ import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final, final
|
||||
from typing import Any, Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import attr
|
||||
from propcache.api import cached_property, under_cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
class CameraView(HomeAssistantView):
|
||||
"""Base CameraView."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, component: EntityComponent[Camera]) -> None:
|
||||
"""Initialize a basic camera view."""
|
||||
self.component = component
|
||||
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return camera.access_tokens
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
if (camera := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in camera.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or camera access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if not camera.is_on:
|
||||
_LOGGER.debug("Camera is off")
|
||||
raise web.HTTPServiceUnavailable
|
||||
|
||||
@@ -6,7 +6,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import area_registry as ar
|
||||
from homeassistant.helpers import area_registry as ar, label_registry as lr
|
||||
|
||||
|
||||
@callback
|
||||
@@ -69,8 +69,9 @@ def websocket_create_area(
|
||||
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
if "labels" in data:
|
||||
# Convert labels to a set
|
||||
data["labels"] = set(data["labels"])
|
||||
# Strip labels which are not in the label registry
|
||||
labels = set(data["labels"])
|
||||
data["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
|
||||
|
||||
try:
|
||||
entry = registry.async_create(**data)
|
||||
@@ -139,8 +140,11 @@ def websocket_update_area(
|
||||
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
if "labels" in data:
|
||||
# Convert labels to a set
|
||||
data["labels"] = set(data["labels"])
|
||||
# Strip labels which are not in the label registry. This also cleans up
|
||||
# any stale labels already stored on the area (e.g. left behind by a
|
||||
# deleted label) the next time it is saved.
|
||||
labels = set(data["labels"])
|
||||
data["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
|
||||
|
||||
try:
|
||||
entry = registry.async_update(**data)
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.components.websocket_api import require_admin
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, label_registry as lr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry, DeviceEntryDisabler
|
||||
|
||||
|
||||
@@ -84,8 +84,11 @@ def websocket_update_device(
|
||||
msg["disabled_by"] = DeviceEntryDisabler(msg["disabled_by"])
|
||||
|
||||
if "labels" in msg:
|
||||
# Convert labels to a set
|
||||
msg["labels"] = set(msg["labels"])
|
||||
# Strip labels which are not in the label registry. This also cleans up
|
||||
# any stale labels already stored on the device (e.g. left behind by a
|
||||
# deleted label) the next time it is saved.
|
||||
labels = set(msg["labels"])
|
||||
msg["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
|
||||
|
||||
entry = cast(DeviceEntry, registry.async_update_device(**msg))
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
label_registry as lr,
|
||||
)
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
|
||||
@@ -234,8 +235,11 @@ def websocket_update_entity(
|
||||
aliases.append(alias)
|
||||
|
||||
if "labels" in msg:
|
||||
# Convert labels to a set
|
||||
changes["labels"] = set(msg["labels"])
|
||||
# Strip labels which are not in the label registry. This also cleans up
|
||||
# any stale labels already stored on the entity (e.g. left behind by a
|
||||
# deleted label) the next time it is saved.
|
||||
labels = set(msg["labels"])
|
||||
changes["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
|
||||
|
||||
if "disabled_by" in msg and msg["disabled_by"] is None:
|
||||
# Don't allow enabling an entity of a disabled device
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"]
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Diagnostics support for Daikin."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import KEY_MAC
|
||||
from .coordinator import DaikinConfigEntry
|
||||
|
||||
TO_REDACT_ENTRY = {CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID, KEY_MAC}
|
||||
TO_REDACT_DEVICE = {"mac"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: DaikinConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
device = entry.runtime_data.device
|
||||
return {
|
||||
"entry_data": async_redact_data(dict(entry.data), TO_REDACT_ENTRY),
|
||||
"device": {
|
||||
"values": async_redact_data(dict(device.values), TO_REDACT_DEVICE),
|
||||
"support_away_mode": device.support_away_mode,
|
||||
"support_advanced_modes": device.support_advanced_modes,
|
||||
"support_fan_rate": device.support_fan_rate,
|
||||
"support_swing_mode": device.support_swing_mode,
|
||||
"support_outside_temperature": device.support_outside_temperature,
|
||||
"support_humidity": device.support_humidity,
|
||||
"support_energy_consumption": device.support_energy_consumption,
|
||||
"support_compressor_frequency": device.support_compressor_frequency,
|
||||
},
|
||||
}
|
||||
@@ -31,29 +31,6 @@ async def _async_get_write_requests_remaining(
|
||||
return {"type": "failed", "error": "unreachable"}
|
||||
|
||||
|
||||
def _entry_write_requests_remaining_key(config_entry: DucoConfigEntry) -> str:
|
||||
"""Return the identifying label for a config entry quota."""
|
||||
identifier = config_entry.unique_id or config_entry.entry_id
|
||||
return f"{config_entry.title or config_entry.entry_id} ({identifier})"
|
||||
|
||||
|
||||
async def _async_get_write_requests_remaining_summary(
|
||||
config_entries: list[DucoConfigEntry],
|
||||
) -> str:
|
||||
"""Get a per-entry write-request summary for system health."""
|
||||
# Keep one translated system health label; multiple Duco boxes are
|
||||
# summarized in the value to avoid ambiguous per-entry labels.
|
||||
summaries: list[str] = []
|
||||
for config_entry in config_entries:
|
||||
result = await _async_get_write_requests_remaining(config_entry)
|
||||
summaries.append(
|
||||
f"{_entry_write_requests_remaining_key(config_entry)}: "
|
||||
f"{result if not isinstance(result, dict) else f'Failed: {result["error"]}'}"
|
||||
)
|
||||
|
||||
return "; ".join(summaries)
|
||||
|
||||
|
||||
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Get info for the info page."""
|
||||
config_entries: list[DucoConfigEntry] = hass.config_entries.async_loaded_entries(
|
||||
@@ -63,15 +40,8 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
if not config_entries:
|
||||
return {}
|
||||
|
||||
if len(config_entries) == 1:
|
||||
return {
|
||||
"write_requests_remaining": _async_get_write_requests_remaining(
|
||||
config_entries[0]
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
"write_requests_remaining": _async_get_write_requests_remaining_summary(
|
||||
config_entries
|
||||
"write_requests_remaining": _async_get_write_requests_remaining(
|
||||
config_entries[0]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sml"],
|
||||
"requirements": ["pysml==0.1.8"]
|
||||
"requirements": ["pysml==0.1.7"]
|
||||
}
|
||||
|
||||
@@ -11,20 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_RADAR_LAYER,
|
||||
CONF_RADAR_LEGEND,
|
||||
CONF_RADAR_OPACITY,
|
||||
CONF_RADAR_RADIUS,
|
||||
CONF_RADAR_TIMESTAMP,
|
||||
CONF_STATION,
|
||||
DEFAULT_RADAR_LAYER,
|
||||
DEFAULT_RADAR_LEGEND,
|
||||
DEFAULT_RADAR_OPACITY,
|
||||
DEFAULT_RADAR_RADIUS,
|
||||
DEFAULT_RADAR_TIMESTAMP,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import CONF_STATION, DOMAIN
|
||||
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -67,15 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada weather")
|
||||
|
||||
options = config_entry.options
|
||||
radar_data = ECMap(
|
||||
coordinates=(lat, lon),
|
||||
layer=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
|
||||
legend=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
|
||||
timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
|
||||
layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)),
|
||||
radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)),
|
||||
)
|
||||
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
|
||||
radar_coordinator = ECDataUpdateCoordinator(
|
||||
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
@@ -9,42 +9,17 @@ from env_canada import ECWeather, ec_exc
|
||||
from env_canada.ec_weather import get_ec_sites_list
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
NumberSelectorMode,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_RADAR_LAYER,
|
||||
CONF_RADAR_LEGEND,
|
||||
CONF_RADAR_OPACITY,
|
||||
CONF_RADAR_RADIUS,
|
||||
CONF_RADAR_TIMESTAMP,
|
||||
CONF_STATION,
|
||||
CONF_TITLE,
|
||||
DEFAULT_RADAR_LAYER,
|
||||
DEFAULT_RADAR_LEGEND,
|
||||
DEFAULT_RADAR_OPACITY,
|
||||
DEFAULT_RADAR_RADIUS,
|
||||
DEFAULT_RADAR_TIMESTAMP,
|
||||
DOMAIN,
|
||||
RADAR_LAYERS,
|
||||
)
|
||||
from .const import CONF_STATION, CONF_TITLE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,14 +57,6 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
_station_codes: list[dict[str, str]] | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Return the options flow handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
async def _get_station_codes(self) -> list[dict[str, str]]:
|
||||
"""Get station codes, cached after first call."""
|
||||
if self._station_codes is None:
|
||||
@@ -160,55 +127,3 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
"""Handle Environment Canada radar camera options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the radar camera options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
options = self.config_entry.options
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_RADAR_LAYER,
|
||||
default=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=RADAR_LAYERS,
|
||||
translation_key="radar_layer",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RADAR_LEGEND,
|
||||
default=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
|
||||
): BooleanSelector(),
|
||||
vol.Required(
|
||||
CONF_RADAR_TIMESTAMP,
|
||||
default=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
|
||||
): BooleanSelector(),
|
||||
vol.Required(
|
||||
CONF_RADAR_OPACITY,
|
||||
default=options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY),
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0, max=100, step=1, mode=NumberSelectorMode.SLIDER
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RADAR_RADIUS,
|
||||
default=options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS),
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=10, max=2000, step=10, unit_of_measurement="km"
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
||||
@@ -6,19 +6,3 @@ CONF_STATION = "station"
|
||||
CONF_TITLE = "title"
|
||||
DOMAIN = "environment_canada"
|
||||
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
|
||||
|
||||
CONF_RADAR_LAYER = "radar_layer"
|
||||
CONF_RADAR_LEGEND = "radar_legend"
|
||||
CONF_RADAR_TIMESTAMP = "radar_timestamp"
|
||||
CONF_RADAR_OPACITY = "radar_opacity"
|
||||
CONF_RADAR_RADIUS = "radar_radius"
|
||||
|
||||
RADAR_LAYERS = ["rain", "snow", "precip_type"]
|
||||
|
||||
# Defaults preserve the radar behaviour from before the options flow existed:
|
||||
# the precipitation-type layer with the legend hidden.
|
||||
DEFAULT_RADAR_LAYER = "precip_type"
|
||||
DEFAULT_RADAR_LEGEND = False
|
||||
DEFAULT_RADAR_TIMESTAMP = True
|
||||
DEFAULT_RADAR_OPACITY = 65
|
||||
DEFAULT_RADAR_RADIUS = 200
|
||||
|
||||
@@ -117,33 +117,6 @@
|
||||
"message": "Environment Canada is not connected"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"radar_layer": "Radar type",
|
||||
"radar_legend": "Show legend",
|
||||
"radar_opacity": "Radar opacity",
|
||||
"radar_radius": "Map radius",
|
||||
"radar_timestamp": "Show timestamp"
|
||||
},
|
||||
"data_description": {
|
||||
"radar_opacity": "Opacity of the radar layer overlay (0-100)",
|
||||
"radar_radius": "Radius of the radar map in kilometres"
|
||||
},
|
||||
"title": "Radar camera options"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"radar_layer": {
|
||||
"options": {
|
||||
"precip_type": "Precipitation type",
|
||||
"rain": "Rain",
|
||||
"snow": "Snow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_alerts": {
|
||||
"description": "Retrieves the alerts from the selected weather service.",
|
||||
|
||||
@@ -27,7 +27,6 @@ from epson_projector.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
@@ -63,7 +62,6 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_device_class = MediaPlayerDeviceClass.PROJECTOR
|
||||
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.TURN_ON
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.6"]
|
||||
"requirements": ["home-assistant-frontend==20260527.5"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContourBattery,
|
||||
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
|
||||
super()._handle_coordinator_update()
|
||||
return
|
||||
|
||||
time = dt_util.utcnow() + timedelta(seconds=value)
|
||||
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
|
||||
if not self._attr_native_value:
|
||||
self._attr_native_value = time
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to set up {name}?\n\nBefore you continue, make sure the device is in pairing mode."
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
|
||||
@@ -14,12 +14,7 @@ from homeassistant.helpers.aiohttp_client import (
|
||||
)
|
||||
|
||||
from .const import CONF_REPOSITORIES, CONF_REPOSITORY, DOMAIN, SUBENTRY_TYPE_REPOSITORY
|
||||
from .coordinator import (
|
||||
GithubConfigEntry,
|
||||
GitHubDataUpdateCoordinator,
|
||||
GitHubRuntimeData,
|
||||
GitHubUserDataUpdateCoordinator,
|
||||
)
|
||||
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
@@ -32,14 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
|
||||
client_name=SERVER_SOFTWARE,
|
||||
)
|
||||
|
||||
user_coordinator = GitHubUserDataUpdateCoordinator(
|
||||
hass=hass,
|
||||
config_entry=entry,
|
||||
client=client,
|
||||
)
|
||||
await user_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
repositories: dict[str, GitHubDataUpdateCoordinator] = {}
|
||||
entry.runtime_data = {}
|
||||
for repository_subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_REPOSITORY):
|
||||
repository = repository_subentry.data[CONF_REPOSITORY]
|
||||
coordinator = GitHubDataUpdateCoordinator(
|
||||
@@ -54,12 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
|
||||
if not entry.pref_disable_polling:
|
||||
await coordinator.subscribe()
|
||||
|
||||
repositories[repository_subentry.subentry_id] = coordinator
|
||||
|
||||
entry.runtime_data = GitHubRuntimeData(
|
||||
user_coordinator=user_coordinator,
|
||||
repositories=repositories,
|
||||
)
|
||||
entry.runtime_data[repository_subentry.subentry_id] = coordinator
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_entry))
|
||||
|
||||
@@ -74,7 +57,8 @@ async def async_update_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> N
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
for coordinator in entry.runtime_data.repositories.values():
|
||||
repositories = entry.runtime_data
|
||||
for coordinator in repositories.values():
|
||||
coordinator.unsubscribe()
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Custom data update coordinator for the GitHub integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from aiogithubapi import (
|
||||
GitHubAPI,
|
||||
GitHubAuthenticatedUserModel,
|
||||
GitHubConnectionException,
|
||||
GitHubEventModel,
|
||||
GitHubException,
|
||||
@@ -105,52 +103,7 @@ query ($owner: String!, $repository: String!) {
|
||||
}
|
||||
"""
|
||||
|
||||
type GithubConfigEntry = ConfigEntry[GitHubRuntimeData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitHubRuntimeData:
|
||||
"""Runtime data for the GitHub integration."""
|
||||
|
||||
user_coordinator: GitHubUserDataUpdateCoordinator
|
||||
repositories: dict[str, GitHubDataUpdateCoordinator]
|
||||
|
||||
|
||||
class GitHubUserDataUpdateCoordinator(
|
||||
DataUpdateCoordinator[GitHubAuthenticatedUserModel]
|
||||
):
|
||||
"""Data update coordinator for the authenticated GitHub user."""
|
||||
|
||||
config_entry: GithubConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GithubConfigEntry,
|
||||
client: GitHubAPI,
|
||||
) -> None:
|
||||
"""Initialize GitHub user data update coordinator."""
|
||||
self._client = client
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="user",
|
||||
update_interval=FALLBACK_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> GitHubAuthenticatedUserModel:
|
||||
"""Update data."""
|
||||
try:
|
||||
response = await self._client.user.get()
|
||||
except (GitHubConnectionException, GitHubRatelimitException) as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
except GitHubException as exception:
|
||||
LOGGER.exception(exception)
|
||||
raise UpdateFailed(exception) from exception
|
||||
|
||||
return response.data
|
||||
type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]]
|
||||
|
||||
|
||||
class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
@@ -33,7 +33,7 @@ async def async_get_config_entry_diagnostics(
|
||||
else:
|
||||
data["rate_limit"] = rate_limit_response.data.as_dict
|
||||
|
||||
repositories = config_entry.runtime_data.repositories
|
||||
repositories = config_entry.runtime_data
|
||||
data["repositories"] = {}
|
||||
|
||||
for coordinator in repositories.values():
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
"discussions_count": {
|
||||
"default": "mdi:forum"
|
||||
},
|
||||
"followers": {
|
||||
"default": "mdi:account-multiple"
|
||||
},
|
||||
"following": {
|
||||
"default": "mdi:account-multiple-outline"
|
||||
},
|
||||
"forks_count": {
|
||||
"default": "mdi:source-fork"
|
||||
},
|
||||
@@ -37,12 +31,6 @@
|
||||
"merged_pulls_count": {
|
||||
"default": "mdi:source-merge"
|
||||
},
|
||||
"public_gists": {
|
||||
"default": "mdi:code-json"
|
||||
},
|
||||
"public_repos": {
|
||||
"default": "mdi:source-repository"
|
||||
},
|
||||
"pulls_count": {
|
||||
"default": "mdi:source-pull"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from aiogithubapi import GitHubAuthenticatedUserModel
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
@@ -19,11 +17,7 @@ from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
GithubConfigEntry,
|
||||
GitHubDataUpdateCoordinator,
|
||||
GitHubUserDataUpdateCoordinator,
|
||||
)
|
||||
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -147,58 +141,14 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GitHubUserSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes GitHub user sensor entity."""
|
||||
|
||||
value_fn: Callable[[GitHubAuthenticatedUserModel], StateType]
|
||||
|
||||
|
||||
USER_SENSOR_DESCRIPTIONS: tuple[GitHubUserSensorEntityDescription, ...] = (
|
||||
GitHubUserSensorEntityDescription(
|
||||
key="followers",
|
||||
translation_key="followers",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.followers,
|
||||
),
|
||||
GitHubUserSensorEntityDescription(
|
||||
key="following",
|
||||
translation_key="following",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.following,
|
||||
),
|
||||
GitHubUserSensorEntityDescription(
|
||||
key="public_gists",
|
||||
translation_key="public_gists",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.public_gists,
|
||||
),
|
||||
GitHubUserSensorEntityDescription(
|
||||
key="public_repos",
|
||||
translation_key="public_repos",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.public_repos,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GithubConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up GitHub sensor based on a config entry."""
|
||||
user_coordinator = entry.runtime_data.user_coordinator
|
||||
async_add_entities(
|
||||
GitHubUserSensorEntity(user_coordinator, description)
|
||||
for description in USER_SENSOR_DESCRIPTIONS
|
||||
)
|
||||
|
||||
for subentry_id, coordinator in entry.runtime_data.repositories.items():
|
||||
repositories = entry.runtime_data
|
||||
for subentry_id, coordinator in repositories.items():
|
||||
async_add_entities(
|
||||
(
|
||||
GitHubSensorEntity(coordinator, description)
|
||||
@@ -253,37 +203,3 @@ class GitHubSensorEntity(CoordinatorEntity[GitHubDataUpdateCoordinator], SensorE
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return the extra state attributes."""
|
||||
return self.entity_description.attr_fn(self.coordinator.data)
|
||||
|
||||
|
||||
class GitHubUserSensorEntity(
|
||||
CoordinatorEntity[GitHubUserDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Defines a GitHub user sensor entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
entity_description: GitHubUserSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GitHubUserDataUpdateCoordinator,
|
||||
entity_description: GitHubUserSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{coordinator.data.id}_{entity_description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(coordinator.data.id))},
|
||||
name=coordinator.data.login,
|
||||
manufacturer="GitHub",
|
||||
configuration_url=f"https://github.com/{coordinator.data.login}",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
@@ -36,14 +36,6 @@
|
||||
"name": "Discussions",
|
||||
"unit_of_measurement": "discussions"
|
||||
},
|
||||
"followers": {
|
||||
"name": "Followers",
|
||||
"unit_of_measurement": "followers"
|
||||
},
|
||||
"following": {
|
||||
"name": "Following",
|
||||
"unit_of_measurement": "users"
|
||||
},
|
||||
"forks_count": {
|
||||
"name": "Forks",
|
||||
"unit_of_measurement": "forks"
|
||||
@@ -74,14 +66,6 @@
|
||||
"name": "Merged pull requests",
|
||||
"unit_of_measurement": "pull requests"
|
||||
},
|
||||
"public_gists": {
|
||||
"name": "Public gists",
|
||||
"unit_of_measurement": "gists"
|
||||
},
|
||||
"public_repos": {
|
||||
"name": "Public repositories",
|
||||
"unit_of_measurement": "repositories"
|
||||
},
|
||||
"pulls_count": {
|
||||
"name": "Pull requests",
|
||||
"unit_of_measurement": "pull requests"
|
||||
|
||||
@@ -155,7 +155,7 @@ class GoogleWifiSensor(SensorEntity):
|
||||
class GoogleWifiAPI:
|
||||
"""Get the latest data and update the states."""
|
||||
|
||||
def __init__(self, host, conditions) -> None:
|
||||
def __init__(self, host, conditions):
|
||||
"""Initialize the data object."""
|
||||
uri = "http://"
|
||||
resource = f"{uri}{host}{ENDPOINT}"
|
||||
@@ -182,7 +182,7 @@ class GoogleWifiAPI:
|
||||
self.raw_data = response.json()
|
||||
self.data_format()
|
||||
self.available = True
|
||||
except ValueError, requests.exceptions.RequestException:
|
||||
except ValueError, requests.exceptions.ConnectionError:
|
||||
_LOGGER.warning("Unable to fetch data from Google Wifi")
|
||||
self.available = False
|
||||
self.raw_data = None
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.13.0"]
|
||||
"requirements": ["homematicip==2.12.0"]
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
|
||||
entity_description = LightEntityDescription(
|
||||
key="hue_grouped_light",
|
||||
translation_key="hue_grouped_light",
|
||||
has_entity_name=True,
|
||||
name=None,
|
||||
)
|
||||
|
||||
@@ -166,10 +166,8 @@ class HueLightLevelSensor(HueSensorBase):
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
def native_value(self) -> int:
|
||||
"""Return the value reported by the sensor."""
|
||||
if self.resource.light.value is None:
|
||||
return None
|
||||
# Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm
|
||||
# scale used because the human eye adjusts to light levels and small
|
||||
# changes at low lux levels are more noticeable than at high lux
|
||||
|
||||
@@ -7,7 +7,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_OEM, DEFAULT_OEM
|
||||
from .coordinator import HypontechConfigEntry, HypontechDataCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
@@ -20,7 +19,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypontechConfigEntry) ->
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
session,
|
||||
oem=int(entry.data.get(CONF_OEM, DEFAULT_OEM)),
|
||||
)
|
||||
try:
|
||||
await hypontech_cloud.connect()
|
||||
|
||||
@@ -4,29 +4,18 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hyponcloud import KNOWN_OEMS, AdminInfo, AuthenticationError, HyponCloud
|
||||
from hyponcloud import AuthenticationError, HyponCloud
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_OEM, DEFAULT_OEM, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
OEM_OPTIONS = [
|
||||
SelectOptionDict(value=str(oem.id), label=f"{oem.name} ({oem.monitoring_url})")
|
||||
for oem in KNOWN_OEMS
|
||||
]
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
@@ -34,128 +23,52 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def _data_schema(default_oem: int = DEFAULT_OEM) -> vol.Schema:
|
||||
"""Return the config flow data schema."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_OEM, default=str(default_oem)): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=OEM_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _entry_data(user_input: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize config entry data from user input."""
|
||||
return {**user_input, CONF_OEM: int(user_input[CONF_OEM])}
|
||||
|
||||
|
||||
def _unique_id(account_id: str, oem: int) -> str:
|
||||
"""Return a backwards-compatible unique id for the account and OEM."""
|
||||
if oem == DEFAULT_OEM:
|
||||
return account_id
|
||||
return f"{oem}:{account_id}"
|
||||
|
||||
|
||||
class HypontechConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Hypontech Cloud."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._default_oem = DEFAULT_OEM
|
||||
|
||||
async def _async_validate_input(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> tuple[AdminInfo | None, dict[str, str]]:
|
||||
"""Validate user input."""
|
||||
errors: dict[str, str] = {}
|
||||
session = async_get_clientsession(self.hass)
|
||||
hypon = HyponCloud(
|
||||
entry_data[CONF_USERNAME],
|
||||
entry_data[CONF_PASSWORD],
|
||||
session,
|
||||
oem=entry_data[CONF_OEM],
|
||||
)
|
||||
try:
|
||||
await hypon.connect()
|
||||
return await hypon.get_admin_info(), errors
|
||||
except AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TimeoutError, ConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
return None, errors
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
default_oem = self._default_oem
|
||||
if user_input is not None:
|
||||
entry_data = _entry_data(user_input)
|
||||
default_oem = entry_data[CONF_OEM]
|
||||
admin_info, errors = await self._async_validate_input(entry_data)
|
||||
if admin_info is not None:
|
||||
await self.async_set_unique_id(
|
||||
_unique_id(admin_info.id, entry_data[CONF_OEM])
|
||||
)
|
||||
session = async_get_clientsession(self.hass)
|
||||
hypon = HyponCloud(
|
||||
user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session
|
||||
)
|
||||
try:
|
||||
await hypon.connect()
|
||||
admin_info = await hypon.get_admin_info()
|
||||
except AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TimeoutError, ConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(admin_info.id)
|
||||
if self.source == SOURCE_USER:
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=entry_data[CONF_USERNAME],
|
||||
data=entry_data,
|
||||
title=user_input[CONF_USERNAME],
|
||||
data=user_input,
|
||||
)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={
|
||||
CONF_USERNAME: entry_data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry_data[CONF_PASSWORD],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=_data_schema(default_oem), errors=errors
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
self._default_oem = int(entry_data.get(CONF_OEM, DEFAULT_OEM))
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
entry_data = {**user_input, CONF_OEM: self._default_oem}
|
||||
admin_info, errors = await self._async_validate_input(entry_data)
|
||||
if admin_info is not None:
|
||||
await self.async_set_unique_id(
|
||||
_unique_id(admin_info.id, entry_data[CONF_OEM])
|
||||
)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={
|
||||
CONF_USERNAME: entry_data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry_data[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
@@ -4,7 +4,4 @@ from logging import Logger, getLogger
|
||||
|
||||
DOMAIN = "hypontech"
|
||||
|
||||
CONF_OEM = "oem"
|
||||
DEFAULT_OEM = 0
|
||||
|
||||
LOGGER: Logger = getLogger(__package__)
|
||||
|
||||
@@ -5,7 +5,6 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from hyponcloud import (
|
||||
KNOWN_OEMS,
|
||||
HyponCloud,
|
||||
OverviewData,
|
||||
PlantData,
|
||||
@@ -17,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_OEM, DEFAULT_OEM, DOMAIN, LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -38,8 +37,6 @@ class HypontechCoordinatorData:
|
||||
|
||||
type HypontechConfigEntry = ConfigEntry[HypontechDataCoordinator]
|
||||
|
||||
OEM_NAMES = {oem.id: oem.name for oem in KNOWN_OEMS}
|
||||
|
||||
|
||||
class HypontechDataCoordinator(DataUpdateCoordinator[HypontechCoordinatorData]):
|
||||
"""Coordinator used for all sensors."""
|
||||
@@ -63,7 +60,6 @@ class HypontechDataCoordinator(DataUpdateCoordinator[HypontechCoordinatorData]):
|
||||
)
|
||||
self.api = api
|
||||
self.account_id = account_id
|
||||
self.oem_name = OEM_NAMES[int(config_entry.data.get(CONF_OEM, DEFAULT_OEM))]
|
||||
|
||||
async def _async_update_data(self) -> HypontechCoordinatorData:
|
||||
try:
|
||||
|
||||
@@ -18,7 +18,7 @@ class HypontechEntity(CoordinatorEntity[HypontechDataCoordinator]):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.account_id)},
|
||||
name="Overview",
|
||||
manufacturer=coordinator.oem_name,
|
||||
manufacturer="Hypontech",
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class HypontechPlantEntity(CoordinatorEntity[HypontechDataCoordinator]):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, plant_id)},
|
||||
name=plant.info.plant_name,
|
||||
manufacturer=coordinator.oem_name,
|
||||
manufacturer="Hypontech",
|
||||
model=plant.info.plant_type,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hyponcloud==1.0.1"]
|
||||
"requirements": ["hyponcloud==1.0.0"]
|
||||
}
|
||||
|
||||
@@ -24,12 +24,10 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"oem": "Manufacturer",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"oem": "The brand that sold the equipment",
|
||||
"password": "Your Hypontech Cloud account password.",
|
||||
"username": "Your Hypontech Cloud account username."
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Diagnostics platform for iAquaLink."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AqualinkConfigEntry
|
||||
|
||||
TO_REDACT = {"serial", "serial_number"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AqualinkConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
systems = [
|
||||
{
|
||||
"online": coordinator.system.online,
|
||||
"data": {k: v for k, v in coordinator.system.data.items() if k != "name"},
|
||||
"devices": {
|
||||
name: {"class": obj.__class__.__name__, "data": obj.data}
|
||||
for name, obj in (
|
||||
getattr(coordinator.system, "devices", None) or {}
|
||||
).items()
|
||||
},
|
||||
}
|
||||
for coordinator in entry.runtime_data.coordinators.values()
|
||||
]
|
||||
|
||||
return {"systems": async_redact_data(systems, TO_REDACT)}
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration uses a cloud account.
|
||||
|
||||
@@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
await hass.async_add_executor_job(account.setup)
|
||||
|
||||
entry.runtime_data = account
|
||||
entry.async_on_unload(account.cancel_fetch)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -67,6 +68,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await hass.async_add_executor_job(entry.runtime_data.cancel_fetch)
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Container, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
from typing import Final, final
|
||||
from typing import Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import httpx
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import (
|
||||
@@ -314,33 +315,28 @@ class ImageView(HomeAssistantView):
|
||||
"""View to serve an image."""
|
||||
|
||||
name = "api:image:image"
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
url = "/api/image_proxy/{entity_id}"
|
||||
|
||||
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
|
||||
"""Initialize an image view."""
|
||||
self.component = component
|
||||
|
||||
async def _authenticate_request(
|
||||
self, request: web.Request, entity_id: str
|
||||
) -> ImageEntity:
|
||||
"""Authenticate request and return image entity."""
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return image_entity.access_tokens
|
||||
|
||||
@callback
|
||||
def _get_image_entity(self, entity_id: str) -> ImageEntity:
|
||||
"""Get image entity from request."""
|
||||
if (image_entity := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in image_entity.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or image entity access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
return image_entity
|
||||
|
||||
async def head(self, request: web.Request, entity_id: str) -> web.Response:
|
||||
@@ -349,7 +345,7 @@ class ImageView(HomeAssistantView):
|
||||
This is sent by some DLNA renderers, like Samsung ones, prior to sending
|
||||
the GET request.
|
||||
"""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
image_entity = self._get_image_entity(entity_id)
|
||||
|
||||
# Don't use `handle` as we don't care about the stream case, we only want
|
||||
# to verify that the image exists.
|
||||
@@ -365,7 +361,7 @@ class ImageView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
image_entity = self._get_image_entity(entity_id)
|
||||
return await self.handle(request, image_entity)
|
||||
|
||||
async def handle(
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Support for Imou camera entities."""
|
||||
|
||||
from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE
|
||||
from pyimouapi.exceptions import ImouException
|
||||
from pyimouapi.ha_device import ImouHaDevice
|
||||
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import PARAM_HEADER_DETECT, imou_device_identifier
|
||||
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
|
||||
from .entity import ImouEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
CAMERA_STREAM_RESOLUTION_SD = "SD"
|
||||
|
||||
# Defaults for pyimouapi ImouHaDeviceManager APIs (async_get_device_stream / async_get_device_image).
|
||||
PYIMOUAPI_LIVE_PROTOCOL = "https"
|
||||
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3
|
||||
|
||||
CAMERA_TYPES = (
|
||||
("camera_sd", CAMERA_STREAM_RESOLUTION_SD),
|
||||
("camera_hd", PARAM_HD),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ImouConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Imou camera entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _add_cameras(new_devices: list[ImouHaDevice]) -> None:
|
||||
device_keys = {imou_device_identifier(device) for device in new_devices}
|
||||
async_add_entities(
|
||||
ImouCamera(coordinator, entity_type, device, resolution)
|
||||
for device in coordinator.devices
|
||||
if device.channel_id is not None
|
||||
if imou_device_identifier(device) in device_keys
|
||||
for entity_type, resolution in CAMERA_TYPES
|
||||
)
|
||||
|
||||
coordinator.new_device_callbacks.append(_add_cameras)
|
||||
|
||||
@callback
|
||||
def _remove_new_device_callback() -> None:
|
||||
if _add_cameras in coordinator.new_device_callbacks:
|
||||
coordinator.new_device_callbacks.remove(_add_cameras)
|
||||
|
||||
entry.async_on_unload(_remove_new_device_callback)
|
||||
_add_cameras(coordinator.devices)
|
||||
|
||||
|
||||
class ImouCamera(ImouEntity, Camera):
|
||||
"""Representation of an Imou camera stream."""
|
||||
|
||||
_attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ImouDataUpdateCoordinator,
|
||||
entity_type: str,
|
||||
device: ImouHaDevice,
|
||||
resolution: str,
|
||||
) -> None:
|
||||
"""Initialize the camera entity."""
|
||||
self._resolution = resolution
|
||||
Camera.__init__(self)
|
||||
super().__init__(coordinator, entity_type, device)
|
||||
|
||||
async def stream_source(self) -> str | None:
|
||||
"""Return the live stream URL from the Imou cloud."""
|
||||
try:
|
||||
return await self.coordinator.device_manager.async_get_device_stream(
|
||||
self.device,
|
||||
self._resolution,
|
||||
PYIMOUAPI_LIVE_PROTOCOL,
|
||||
)
|
||||
except ImouException as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return bytes of camera image."""
|
||||
try:
|
||||
return await self.coordinator.device_manager.async_get_device_image(
|
||||
self.device,
|
||||
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS,
|
||||
)
|
||||
except ImouException as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
@property
|
||||
def motion_detection_enabled(self) -> bool:
|
||||
"""Return True when human and/or motion detection switch is on."""
|
||||
header = self.device.switches.get(PARAM_HEADER_DETECT)
|
||||
motion = self.device.switches.get(PARAM_MOTION_DETECT)
|
||||
header_on = bool(header[PARAM_STATE]) if header else False
|
||||
motion_on = bool(motion[PARAM_STATE]) if motion else False
|
||||
return header_on or motion_on
|
||||
@@ -28,7 +28,7 @@ CONF_APP_SECRET = "app_secret"
|
||||
|
||||
PARAM_STATUS = "status"
|
||||
PARAM_STATE = "state"
|
||||
PARAM_HEADER_DETECT = "header_detect"
|
||||
|
||||
|
||||
# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API).
|
||||
PTZ_MOVE_DURATION_MS = 500
|
||||
@@ -36,4 +36,4 @@ PTZ_MOVE_DURATION_MS = 500
|
||||
# Upper bound for a full coordinator refresh (device list + status for all devices).
|
||||
UPDATE_TIMEOUT = 300
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.CAMERA]
|
||||
PLATFORMS = [Platform.BUTTON]
|
||||
|
||||
@@ -41,14 +41,6 @@
|
||||
"ptz_up": {
|
||||
"name": "PTZ up"
|
||||
}
|
||||
},
|
||||
"camera": {
|
||||
"camera_hd": {
|
||||
"name": "Live view HD"
|
||||
},
|
||||
"camera_sd": {
|
||||
"name": "Live view SD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -423,7 +423,7 @@ def get_influx_connection( # noqa: C901
|
||||
if CONF_HOST in conf:
|
||||
kwargs[CONF_HOST] = conf[CONF_HOST]
|
||||
|
||||
if (path := conf.get(CONF_PATH)) is not None and path != "/":
|
||||
if (path := conf.get(CONF_PATH)) is not None:
|
||||
kwargs[CONF_PATH] = path
|
||||
|
||||
if (port := conf.get(CONF_PORT)) is not None:
|
||||
|
||||
@@ -40,8 +40,6 @@ CONF_COMMANDS = "commands"
|
||||
CONF_DATA = "data"
|
||||
CONF_IR_COUNT = "ir_count"
|
||||
|
||||
EMPTY_COMMAND_PLACEHOLDER = '""'
|
||||
|
||||
PLATFORM_SCHEMA = REMOTE_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_MAC): cv.string,
|
||||
@@ -71,35 +69,6 @@ PLATFORM_SCHEMA = REMOTE_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def _format_command_value(value: str) -> str:
|
||||
"""Format a command name or command data value."""
|
||||
value = value.strip()
|
||||
return value or EMPTY_COMMAND_PLACEHOLDER
|
||||
|
||||
|
||||
def _format_command_table(commands: Iterable[dict[str, str]]) -> str:
|
||||
"""Format YAML commands for pyitachip2ir."""
|
||||
return "".join(
|
||||
f"{_format_command_value(command[CONF_NAME])}\n"
|
||||
f"{_format_command_value(command[CONF_DATA])}\n"
|
||||
for command in commands
|
||||
)
|
||||
|
||||
|
||||
def _setup_remote_entity(
|
||||
itachip2ir: Any, device_config: dict[str, Any]
|
||||
) -> ITachIP2IRRemote:
|
||||
"""Create an iTach remote entity from YAML device config."""
|
||||
name = device_config.get(CONF_NAME)
|
||||
modaddr = int(device_config.get(CONF_MODADDR, DEFAULT_MODADDR))
|
||||
connaddr = int(device_config.get(CONF_CONNADDR, DEFAULT_CONNADDR))
|
||||
ir_count = int(device_config.get(CONF_IR_COUNT, DEFAULT_IR_COUNT))
|
||||
command_table = _format_command_table(device_config[CONF_COMMANDS])
|
||||
|
||||
itachip2ir.addDevice(name, modaddr, connaddr, command_table)
|
||||
return ITachIP2IRRemote(itachip2ir, name, ir_count)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -115,17 +84,30 @@ def setup_platform(
|
||||
_LOGGER.error("Unable to find iTach")
|
||||
return
|
||||
|
||||
devices = [
|
||||
_setup_remote_entity(itachip2ir, device_config)
|
||||
for device_config in config[CONF_DEVICES]
|
||||
]
|
||||
devices = []
|
||||
for data in config[CONF_DEVICES]:
|
||||
name = data.get(CONF_NAME)
|
||||
modaddr = int(data.get(CONF_MODADDR, DEFAULT_MODADDR))
|
||||
connaddr = int(data.get(CONF_CONNADDR, DEFAULT_CONNADDR))
|
||||
ir_count = int(data.get(CONF_IR_COUNT, DEFAULT_IR_COUNT))
|
||||
cmddatas = ""
|
||||
for cmd in data.get(CONF_COMMANDS):
|
||||
cmdname = cmd[CONF_NAME].strip()
|
||||
if not cmdname:
|
||||
cmdname = '""'
|
||||
cmddata = cmd[CONF_DATA].strip()
|
||||
if not cmddata:
|
||||
cmddata = '""'
|
||||
cmddatas += f"{cmdname}\n{cmddata}\n"
|
||||
itachip2ir.addDevice(name, modaddr, connaddr, cmddatas)
|
||||
devices.append(ITachIP2IRRemote(itachip2ir, name, ir_count))
|
||||
add_entities(devices, True)
|
||||
|
||||
|
||||
class ITachIP2IRRemote(remote.RemoteEntity):
|
||||
"""Device that sends commands to an ITachIP2IR device."""
|
||||
|
||||
def __init__(self, itachip2ir: Any, name: str | None, ir_count: int) -> None:
|
||||
def __init__(self, itachip2ir, name, ir_count):
|
||||
"""Initialize device."""
|
||||
self.itachip2ir = itachip2ir
|
||||
self._attr_is_on = False
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""The KlikAanKlikUit RC integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_TRANSMITTER
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KlikAanKlikUitRuntimeData:
|
||||
"""Runtime data for the KlikAanKlikUit integration."""
|
||||
|
||||
transmitter_entity_id: str
|
||||
|
||||
|
||||
type KlikAanKlikUitConfigEntry = ConfigEntry[KlikAanKlikUitRuntimeData]
|
||||
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
|
||||
) -> bool:
|
||||
"""Setup KlikAanKlikUit RC from a config entry."""
|
||||
transmitter_entity_id = entry.data[CONF_TRANSMITTER]
|
||||
if hass.states.get(transmitter_entity_id) is None:
|
||||
raise ConfigEntryNotReady(
|
||||
f"RF transmitter entity {transmitter_entity_id} is not available"
|
||||
)
|
||||
|
||||
entry.runtime_data = KlikAanKlikUitRuntimeData(
|
||||
transmitter_entity_id=transmitter_entity_id
|
||||
)
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_listener))
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_listener(
|
||||
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Config flow for the KlikAanKlikUit RC integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.commands import ModulationType
|
||||
from rf_protocols.commands.kaku import KakuCommand
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import (
|
||||
async_get_transmitters,
|
||||
async_send_command,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE_ID
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from .const import (
|
||||
CONF_CHANNEL,
|
||||
CONF_GROUP,
|
||||
CONF_TRANSMITTER,
|
||||
DOMAIN,
|
||||
REPEAT_COUNT_LEARN,
|
||||
)
|
||||
|
||||
_SAMPLE_COMMAND = KakuCommand(
|
||||
id=0,
|
||||
channel=1,
|
||||
group=False,
|
||||
on=True,
|
||||
)
|
||||
_CONF_DEVICE_RESPONDED = "device_responded"
|
||||
|
||||
|
||||
class KakuRcConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for KlikAanKlikUit."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize config flow."""
|
||||
self._device_data: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle collecting initial setup data."""
|
||||
try:
|
||||
transmitters = async_get_transmitters(
|
||||
self.hass,
|
||||
_SAMPLE_COMMAND.frequency,
|
||||
ModulationType.OOK,
|
||||
)
|
||||
except HomeAssistantError:
|
||||
return self.async_abort(reason="no_transmitters")
|
||||
|
||||
if not transmitters:
|
||||
return self.async_abort(reason="no_compatible_transmitters")
|
||||
|
||||
if user_input is not None:
|
||||
transmitter: str = user_input[CONF_TRANSMITTER]
|
||||
device_id: int = user_input[CONF_DEVICE_ID]
|
||||
channel: int = user_input[CONF_CHANNEL]
|
||||
group: bool = user_input[CONF_GROUP]
|
||||
|
||||
registry = er.async_get(self.hass)
|
||||
entity_entry = registry.async_get(transmitter)
|
||||
assert entity_entry is not None
|
||||
await self.async_set_unique_id(
|
||||
f"{entity_entry.id}_{device_id}_{channel}_{int(group)}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._device_data = {
|
||||
CONF_TRANSMITTER: transmitter,
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_CHANNEL: channel,
|
||||
CONF_GROUP: group,
|
||||
}
|
||||
return await self.async_step_pairing_mode()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self._async_user_schema(transmitters),
|
||||
)
|
||||
|
||||
async def async_step_pairing_mode(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Ask user to put the target device in pairing mode."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="pairing_mode",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
|
||||
assert self._device_data is not None
|
||||
command = KakuCommand(
|
||||
id=self._device_data[CONF_DEVICE_ID],
|
||||
channel=self._device_data[CONF_CHANNEL],
|
||||
group=self._device_data[CONF_GROUP],
|
||||
on=True,
|
||||
frame_repeats=REPEAT_COUNT_LEARN,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass,
|
||||
self._device_data[CONF_TRANSMITTER],
|
||||
command,
|
||||
)
|
||||
return await self.async_step_pairing_result()
|
||||
|
||||
async def async_step_pairing_result(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm whether the device responded to the learn command."""
|
||||
if user_input is not None:
|
||||
if user_input[_CONF_DEVICE_RESPONDED]:
|
||||
assert self._device_data is not None
|
||||
title = (
|
||||
f"KlikAanKlikUit ID {self._device_data[CONF_DEVICE_ID]} "
|
||||
f"CH {self._device_data[CONF_CHANNEL]}"
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data=self._device_data,
|
||||
)
|
||||
|
||||
return await self.async_step_pairing_mode()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="pairing_result",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
_CONF_DEVICE_RESPONDED,
|
||||
default=False,
|
||||
): selector.BooleanSelector()
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def _async_user_schema(
|
||||
self,
|
||||
transmitters: list[str],
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> vol.Schema:
|
||||
"""Build the one-step add form schema."""
|
||||
if user_input is None:
|
||||
user_input = {}
|
||||
|
||||
suggested_values: dict[str, Any] = {
|
||||
CONF_TRANSMITTER: transmitters[0],
|
||||
CONF_CHANNEL: 1,
|
||||
CONF_GROUP: False,
|
||||
}
|
||||
suggested_values.update(user_input)
|
||||
|
||||
return self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(include_entities=transmitters),
|
||||
),
|
||||
vol.Required(CONF_DEVICE_ID): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0,
|
||||
max=0x3FFFFFF,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(CONF_CHANNEL): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=16,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(CONF_GROUP): selector.BooleanSelector(),
|
||||
}
|
||||
),
|
||||
suggested_values,
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Constants and helpers for the KlikAanKlikUit (Kaku) integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import CONF_DEVICE_ID as HA_CONF_DEVICE_ID
|
||||
|
||||
DOMAIN: Final = "klik_aan_klik_uit"
|
||||
|
||||
CONF_TRANSMITTER: Final = "transmitter"
|
||||
CONF_DEVICE_ID: Final = HA_CONF_DEVICE_ID
|
||||
CONF_CHANNEL: Final = "channel"
|
||||
CONF_GROUP: Final = "group"
|
||||
REPEAT_COUNT_LEARN: Final = 10 # Higher repeats for learning/pairing
|
||||
|
||||
|
||||
def format_device_summary(device_id: int, channel: int, group: bool) -> str:
|
||||
"""Return a concise summary string for the configured device."""
|
||||
group_text = "on" if group else "off"
|
||||
return f"ID {device_id} CH {channel} Group {group_text}"
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "klik_aan_klik_uit",
|
||||
"name": "KlikAanKlikUit",
|
||||
"codeowners": ["@Phunkafizer"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["radio_frequency"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/klik_aan_klik_uit",
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide service actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: This integration uses local RF commands and has no account auth.
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: This integration does not use outbound web requests.
|
||||
strict-typing: todo
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_compatible_transmitters": "No compatible radio frequency transmitter is available for this integration.",
|
||||
"no_transmitters": "[%key:common::config_flow::abort::no_radio_frequency_transmitters%]"
|
||||
},
|
||||
"error": {},
|
||||
"step": {
|
||||
"pairing_mode": {
|
||||
"description": "Bring device into learn mode by pushing it's button for more than 2 seconds, then press Ok.",
|
||||
"title": "Pair device"
|
||||
},
|
||||
"pairing_result": {
|
||||
"data": {
|
||||
"device_responded": "Did the device respond?"
|
||||
},
|
||||
"data_description": {
|
||||
"device_responded": "Select Yes if the target device reacted to the learn command."
|
||||
},
|
||||
"description": "Select Yes to continue setup. Select No to return to learn mode and resend the learn command.",
|
||||
"title": "Confirm pairing"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"channel": "Channel",
|
||||
"device_id": "Device ID",
|
||||
"group": "Group",
|
||||
"transmitter": "[%key:common::config_flow::data::radio_frequency_transmitter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "The channel of the target KlikAanKlikUit device (1-16).",
|
||||
"device_id": "The unique KlikAanKlikUit device ID.",
|
||||
"group": "Whether to send commands to the group address instead of a single device.",
|
||||
"transmitter": "[%key:common::config_flow::data_description::radio_frequency_transmitter%]"
|
||||
},
|
||||
"description": "Choose the transmitter and configure your device settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Switch platform for KlikAanKlikUit RC on/off control."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.commands.kaku import KakuCommand
|
||||
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.const import CONF_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from . import KlikAanKlikUitConfigEntry
|
||||
from .const import CONF_CHANNEL, CONF_GROUP, DOMAIN, format_device_summary
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: KlikAanKlikUitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the KlikAanKlikUit switch entity."""
|
||||
async_add_entities([KlikAanKlikUitSwitch(config_entry)])
|
||||
|
||||
|
||||
class KlikAanKlikUitSwitch(SwitchEntity, RestoreEntity):
|
||||
"""Switch entity for KlikAanKlikUit devices."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Output"
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, entry: KlikAanKlikUitConfigEntry) -> None:
|
||||
"""Initialize the switch."""
|
||||
self._transmitter = entry.runtime_data.transmitter_entity_id
|
||||
self._device_id: int = entry.data[CONF_DEVICE_ID]
|
||||
self._channel: int = entry.data[CONF_CHANNEL]
|
||||
self._group: bool = entry.data[CONF_GROUP]
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="KlikAanKlikUit",
|
||||
model="KlikAanKlikUit RC device",
|
||||
sw_version=format_device_summary(
|
||||
self._device_id, self._channel, self._group
|
||||
),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to transmitter state and restore last switch state."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
transmitter_entity_id = er.async_validate_entity_id(
|
||||
er.async_get(self.hass), self._transmitter
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_transmitter_state_changed(
|
||||
event: Event[EventStateChangedData],
|
||||
) -> None:
|
||||
new_state = event.data["new_state"]
|
||||
available = new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
if available != self._attr_available:
|
||||
self._attr_available = available
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[transmitter_entity_id],
|
||||
_async_transmitter_state_changed,
|
||||
)
|
||||
)
|
||||
|
||||
transmitter_state = self.hass.states.get(transmitter_entity_id)
|
||||
self._attr_available = (
|
||||
transmitter_state is not None
|
||||
and transmitter_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
if (last_state := await self.async_get_last_state()) is not None:
|
||||
self._attr_is_on = last_state.state == STATE_ON
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self._async_send(True)
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self._async_send(False)
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send(self, on: bool) -> None:
|
||||
"""Send on/off command."""
|
||||
command = KakuCommand(
|
||||
id=self._device_id,
|
||||
group=self._group,
|
||||
channel=self._channel,
|
||||
on=on,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
)
|
||||
@@ -4,14 +4,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic
|
||||
|
||||
from pylitterbot import (
|
||||
FeederRobot,
|
||||
LitterRobot,
|
||||
LitterRobot3,
|
||||
LitterRobot4,
|
||||
LitterRobot5,
|
||||
Robot,
|
||||
)
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -67,41 +60,6 @@ BINARY_SENSOR_MAP: dict[
|
||||
is_on_fn=lambda robot: not robot.is_hopper_removed,
|
||||
),
|
||||
),
|
||||
LitterRobot5: (
|
||||
RobotBinarySensorEntityDescription[LitterRobot5](
|
||||
key="hopper_connected",
|
||||
translation_key="hopper_connected",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_registry_enabled_default=False,
|
||||
is_on_fn=lambda robot: not robot.is_hopper_removed,
|
||||
),
|
||||
RobotBinarySensorEntityDescription[LitterRobot5](
|
||||
key="drawer_removed",
|
||||
translation_key="drawer_removed",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
is_on_fn=lambda robot: robot.is_drawer_removed,
|
||||
),
|
||||
RobotBinarySensorEntityDescription[LitterRobot5](
|
||||
key="bonnet_removed",
|
||||
translation_key="bonnet_removed",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
is_on_fn=lambda robot: robot.is_bonnet_removed,
|
||||
),
|
||||
RobotBinarySensorEntityDescription[LitterRobot5](
|
||||
key="laser_dirty",
|
||||
translation_key="laser_dirty",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
is_on_fn=lambda robot: robot.is_laser_dirty,
|
||||
),
|
||||
RobotBinarySensorEntityDescription[LitterRobot5](
|
||||
key="online",
|
||||
translation_key="online",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
is_on_fn=lambda robot: robot.is_online,
|
||||
),
|
||||
),
|
||||
(FeederRobot, LitterRobot3, LitterRobot4): (
|
||||
RobotBinarySensorEntityDescription[FeederRobot | LitterRobot3 | LitterRobot4](
|
||||
key="power_status",
|
||||
|
||||
@@ -41,12 +41,6 @@ ROBOT_BUTTON_MAP: dict[tuple[type[Robot], ...], RobotButtonEntityDescription] =
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda robot: robot.reset(),
|
||||
),
|
||||
(LitterRobot5,): RobotButtonEntityDescription[LitterRobot5](
|
||||
key="change_filter",
|
||||
translation_key="change_filter",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda robot: robot.change_filter(),
|
||||
),
|
||||
(FeederRobot,): RobotButtonEntityDescription[FeederRobot](
|
||||
key="give_snack",
|
||||
translation_key="give_snack",
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bonnet_removed": {
|
||||
"default": "mdi:robot-vacuum-alert"
|
||||
},
|
||||
"drawer_removed": {
|
||||
"default": "mdi:robot-vacuum-alert"
|
||||
},
|
||||
"hopper_connected": {
|
||||
"default": "mdi:filter-check"
|
||||
},
|
||||
"laser_dirty": {
|
||||
"default": "mdi:robot-vacuum-alert"
|
||||
},
|
||||
"online": {
|
||||
"default": "mdi:cloud-check"
|
||||
},
|
||||
"sleep_mode": {
|
||||
"default": "mdi:sleep"
|
||||
},
|
||||
@@ -24,9 +12,6 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"change_filter": {
|
||||
"default": "mdi:filter-cog"
|
||||
},
|
||||
"give_snack": {
|
||||
"default": "mdi:candy-outline"
|
||||
},
|
||||
@@ -80,12 +65,6 @@
|
||||
"motor_ot_amps": "mdi:flash-alert"
|
||||
}
|
||||
},
|
||||
"next_filter_replacement": {
|
||||
"default": "mdi:filter-cog"
|
||||
},
|
||||
"scoops_saved_count": {
|
||||
"default": "mdi:counter"
|
||||
},
|
||||
"total_cycles": {
|
||||
"default": "mdi:counter"
|
||||
},
|
||||
|
||||
@@ -166,22 +166,6 @@ ROBOT_SENSOR_MAP: dict[
|
||||
value_fn=lambda robot: robot.pet_weight,
|
||||
),
|
||||
],
|
||||
LitterRobot5: [
|
||||
RobotSensorEntityDescription[LitterRobot5](
|
||||
key="scoops_saved_count",
|
||||
translation_key="scoops_saved_count",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda robot: robot.scoops_saved_count,
|
||||
),
|
||||
RobotSensorEntityDescription[LitterRobot5](
|
||||
key="next_filter_replacement",
|
||||
translation_key="next_filter_replacement",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda robot: robot.next_filter_replacement_date,
|
||||
),
|
||||
],
|
||||
FeederRobot: [
|
||||
RobotSensorEntityDescription[FeederRobot](
|
||||
key="food_dispensed_today",
|
||||
|
||||
@@ -45,21 +45,9 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bonnet_removed": {
|
||||
"name": "Bonnet removed"
|
||||
},
|
||||
"drawer_removed": {
|
||||
"name": "Drawer removed"
|
||||
},
|
||||
"hopper_connected": {
|
||||
"name": "Hopper connected"
|
||||
},
|
||||
"laser_dirty": {
|
||||
"name": "Laser dirty"
|
||||
},
|
||||
"online": {
|
||||
"name": "Online"
|
||||
},
|
||||
"power_status": {
|
||||
"name": "Power status"
|
||||
},
|
||||
@@ -71,9 +59,6 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"change_filter": {
|
||||
"name": "Change filter"
|
||||
},
|
||||
"give_snack": {
|
||||
"name": "Give snack"
|
||||
},
|
||||
@@ -147,16 +132,9 @@
|
||||
"next_feeding": {
|
||||
"name": "Next feeding"
|
||||
},
|
||||
"next_filter_replacement": {
|
||||
"name": "Next filter replacement"
|
||||
},
|
||||
"pet_weight": {
|
||||
"name": "Pet weight"
|
||||
},
|
||||
"scoops_saved_count": {
|
||||
"name": "Scoops saved",
|
||||
"unit_of_measurement": "scoops"
|
||||
},
|
||||
"sleep_mode_end_time": {
|
||||
"name": "Sleep mode end time"
|
||||
},
|
||||
|
||||
@@ -422,7 +422,9 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
"Invalid alarm code provided",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_code",
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Container, Mapping
|
||||
from contextlib import suppress
|
||||
import datetime as dt
|
||||
from enum import StrEnum
|
||||
@@ -12,7 +12,7 @@ import hashlib
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any, Final, Required, TypedDict, final
|
||||
from typing import Any, Final, Required, TypedDict, final, override
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiohttp
|
||||
@@ -24,7 +24,7 @@ import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ( # noqa: F401
|
||||
@@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401
|
||||
STATE_PLAYING,
|
||||
STATE_STANDBY,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
@@ -1249,7 +1249,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
class MediaPlayerImageView(HomeAssistantView):
|
||||
"""Media player view to serve an image."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
url = "/api/media_player_proxy/{entity_id}"
|
||||
name = "api:media_player:image"
|
||||
extra_urls = [
|
||||
@@ -1262,6 +1262,15 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
"""Initialize a media player view."""
|
||||
self.component = component
|
||||
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (player := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return (player.access_token,)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
@@ -1271,21 +1280,9 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
) -> web.Response:
|
||||
"""Start a get request."""
|
||||
if (player := self.component.get_entity(entity_id)) is None:
|
||||
status = (
|
||||
HTTPStatus.NOT_FOUND
|
||||
if request[KEY_AUTHENTICATED]
|
||||
else HTTPStatus.UNAUTHORIZED
|
||||
)
|
||||
return web.Response(status=status)
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
assert isinstance(player, MediaPlayerEntity)
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") == player.access_token
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
return web.Response(status=HTTPStatus.UNAUTHORIZED)
|
||||
|
||||
if media_content_type and media_content_id:
|
||||
media_image_id = request.query.get("media_image_id")
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""The MELCloud Home integration."""
|
||||
|
||||
from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth
|
||||
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: MelCloudHomeConfigEntry
|
||||
) -> bool:
|
||||
"""Set up MELCloud Home from a config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
auth = MelCloudHomeAuth(
|
||||
username=entry.data[CONF_EMAIL],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
client = MELCloudHome(auth=auth, session=session)
|
||||
|
||||
coordinator = MelCloudHomeCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: MelCloudHomeConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Binary sensor platform for MELCloud Home."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiomelcloudhome import ATAUnit, ATWUnit
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ATABinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Class to hold MELCloud Home ATA binary sensor description."""
|
||||
|
||||
state_fn: Callable[[ATAUnit], bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ATWBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Class to hold MELCloud Home ATW binary sensor description."""
|
||||
|
||||
state_fn: Callable[[ATWUnit], bool | None]
|
||||
|
||||
|
||||
ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = (
|
||||
ATABinarySensorEntityDescription(
|
||||
key="error",
|
||||
translation_key="error",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
state_fn=lambda unit: unit.is_in_error,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATABinarySensorEntityDescription(
|
||||
key="standby",
|
||||
translation_key="standby",
|
||||
state_fn=lambda unit: unit.in_standby_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
ATW_SENSORS: tuple[ATWBinarySensorEntityDescription, ...] = (
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="error",
|
||||
translation_key="error",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
state_fn=lambda unit: unit.is_in_error,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="standby",
|
||||
translation_key="standby",
|
||||
state_fn=lambda unit: unit.in_standby_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="forced_hot_water",
|
||||
translation_key="forced_hot_water",
|
||||
state_fn=lambda unit: unit.forced_hot_water_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MelCloudHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MELCloud Home binary sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATABinarySensor(coordinator, entity_description, unit)
|
||||
for entity_description in ATA_SENSORS
|
||||
for unit in units
|
||||
)
|
||||
|
||||
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATWBinarySensor(coordinator, entity_description, unit)
|
||||
for entity_description in ATW_SENSORS
|
||||
for unit in units
|
||||
)
|
||||
|
||||
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
|
||||
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
|
||||
|
||||
_async_add_new_ata_units(list(coordinator.ata_units.values()))
|
||||
_async_add_new_atw_units(list(coordinator.atw_units.values()))
|
||||
|
||||
|
||||
class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity):
|
||||
"""Representation of a MELCloud Home ATA binary sensor."""
|
||||
|
||||
entity_description: ATABinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
entity_description: ATABinarySensorEntityDescription,
|
||||
unit: ATAUnit,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.unit)
|
||||
|
||||
|
||||
class ATWBinarySensor(MelCloudHomeATWUnitEntity, BinarySensorEntity):
|
||||
"""Representation of a MELCloud Home ATW binary sensor."""
|
||||
|
||||
entity_description: ATWBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
entity_description: ATWBinarySensorEntityDescription,
|
||||
unit: ATWUnit,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.unit)
|
||||
@@ -1,373 +0,0 @@
|
||||
"""Climate platform for MELCloud Home."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiomelcloudhome import (
|
||||
ATAFanSpeed,
|
||||
ATAOperationMode,
|
||||
ATAUnit,
|
||||
ATAVaneHorizontal,
|
||||
ATAVaneVertical,
|
||||
ATWUnit,
|
||||
ATWZoneMode,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
ATA_HVAC_MODE_TO_OPERATION: dict[HVACMode, ATAOperationMode] = {
|
||||
HVACMode.HEAT: ATAOperationMode.HEAT,
|
||||
HVACMode.COOL: ATAOperationMode.COOL,
|
||||
HVACMode.AUTO: ATAOperationMode.AUTOMATIC,
|
||||
HVACMode.DRY: ATAOperationMode.DRY,
|
||||
HVACMode.FAN_ONLY: ATAOperationMode.FAN,
|
||||
}
|
||||
|
||||
ATA_OPERATION_TO_HVAC_MODE: dict[ATAOperationMode, HVACMode] = {
|
||||
value: key for key, value in ATA_HVAC_MODE_TO_OPERATION.items()
|
||||
}
|
||||
|
||||
ATA_FAN_SPEED_TO_HA: dict[ATAFanSpeed, str] = {
|
||||
ATAFanSpeed.AUTO: "auto",
|
||||
ATAFanSpeed.ONE: "speed_1",
|
||||
ATAFanSpeed.TWO: "speed_2",
|
||||
ATAFanSpeed.THREE: "speed_3",
|
||||
ATAFanSpeed.FOUR: "speed_4",
|
||||
ATAFanSpeed.FIVE: "speed_5",
|
||||
}
|
||||
|
||||
HA_FAN_SPEED_TO_ATA: dict[str, ATAFanSpeed] = {
|
||||
value: key for key, value in ATA_FAN_SPEED_TO_HA.items()
|
||||
}
|
||||
|
||||
ATA_VANE_VERTICAL_TO_HA: dict[ATAVaneVertical, str] = {
|
||||
ATAVaneVertical.AUTO: "auto",
|
||||
ATAVaneVertical.SWING: "swing",
|
||||
ATAVaneVertical.ONE: "position_1",
|
||||
ATAVaneVertical.TWO: "position_2",
|
||||
ATAVaneVertical.THREE: "position_3",
|
||||
ATAVaneVertical.FOUR: "position_4",
|
||||
ATAVaneVertical.FIVE: "position_5",
|
||||
}
|
||||
|
||||
HA_VANE_VERTICAL_TO_ATA: dict[str, ATAVaneVertical] = {
|
||||
value: key for key, value in ATA_VANE_VERTICAL_TO_HA.items()
|
||||
}
|
||||
|
||||
ATA_VANE_HORIZONTAL_TO_HA: dict[ATAVaneHorizontal, str] = {
|
||||
ATAVaneHorizontal.AUTO: "auto",
|
||||
ATAVaneHorizontal.SWING: "swing",
|
||||
ATAVaneHorizontal.LEFT: "left",
|
||||
ATAVaneHorizontal.LEFT_CENTRE: "left_centre",
|
||||
ATAVaneHorizontal.CENTRE: "centre",
|
||||
ATAVaneHorizontal.RIGHT_CENTRE: "right_centre",
|
||||
ATAVaneHorizontal.RIGHT: "right",
|
||||
}
|
||||
|
||||
HA_VANE_HORIZONTAL_TO_ATA: dict[str, ATAVaneHorizontal] = {
|
||||
value: key for key, value in ATA_VANE_HORIZONTAL_TO_HA.items()
|
||||
}
|
||||
|
||||
ATW_ZONE_MODE_TO_HVAC_MODE: dict[ATWZoneMode, HVACMode] = {
|
||||
ATWZoneMode.HEAT_ROOM_TEMPERATURE: HVACMode.HEAT,
|
||||
ATWZoneMode.HEAT_FLOW_TEMPERATURE: HVACMode.HEAT,
|
||||
ATWZoneMode.HEAT_CURVE: HVACMode.HEAT,
|
||||
ATWZoneMode.COOL_ROOM_TEMPERATURE: HVACMode.COOL,
|
||||
ATWZoneMode.COOL_FLOW_TEMPERATURE: HVACMode.COOL,
|
||||
}
|
||||
|
||||
HVAC_MODE_TO_ATW_ZONE_MODE: dict[HVACMode, ATWZoneMode] = {
|
||||
HVACMode.HEAT: ATWZoneMode.HEAT_ROOM_TEMPERATURE,
|
||||
HVACMode.COOL: ATWZoneMode.COOL_ROOM_TEMPERATURE,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MelCloudHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MELCloud Home climate entities from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
|
||||
async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units)
|
||||
|
||||
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATWZoneClimateEntity(coordinator, unit, zone_number)
|
||||
for unit in units
|
||||
for zone_number in (
|
||||
[1, 2]
|
||||
if (unit.capabilities and unit.capabilities.has_zone2)
|
||||
or (unit.capabilities is None and unit.has_zone2)
|
||||
else [1]
|
||||
)
|
||||
)
|
||||
|
||||
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
|
||||
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
|
||||
|
||||
_async_add_new_ata_units(list(coordinator.ata_units.values()))
|
||||
_async_add_new_atw_units(list(coordinator.atw_units.values()))
|
||||
|
||||
|
||||
class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
|
||||
"""Climate entity for a MELCloud Home Air-to-Air unit."""
|
||||
|
||||
_attr_translation_key = "ata_unit"
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_swing_modes = list(ATA_VANE_VERTICAL_TO_HA.values())
|
||||
_attr_swing_horizontal_modes = list(ATA_VANE_HORIZONTAL_TO_HA.values())
|
||||
|
||||
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: ATAUnit) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
if unit.settings is not None:
|
||||
if unit.settings.get("VaneVerticalDirection") is not None:
|
||||
features |= ClimateEntityFeature.SWING_MODE
|
||||
if unit.settings.get("VaneHorizontalDirection") is not None:
|
||||
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
|
||||
self._attr_supported_features = features
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return HVAC modes supported by this unit based on its capabilities."""
|
||||
if self.unit.capabilities is None:
|
||||
return [
|
||||
HVACMode.OFF,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.COOL,
|
||||
HVACMode.AUTO,
|
||||
HVACMode.DRY,
|
||||
HVACMode.FAN_ONLY,
|
||||
]
|
||||
|
||||
modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
if self.unit.capabilities.has_cool_operation_mode is not False:
|
||||
modes.append(HVACMode.COOL)
|
||||
if self.unit.capabilities.has_auto_operation_mode is not False:
|
||||
modes.append(HVACMode.AUTO)
|
||||
if self.unit.capabilities.has_dry_operation_mode is not False:
|
||||
modes.append(HVACMode.DRY)
|
||||
if self.unit.capabilities.has_fan_operation_mode is not False:
|
||||
modes.append(HVACMode.FAN_ONLY)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
"""Return fan modes supported by this unit based on its capabilities."""
|
||||
capabilities = self.unit.capabilities
|
||||
number = (
|
||||
capabilities.number_of_fan_speeds
|
||||
if capabilities is not None
|
||||
and capabilities.number_of_fan_speeds is not None
|
||||
else len(ATA_FAN_SPEED_TO_HA) - 1
|
||||
)
|
||||
all_speeds = list(ATA_FAN_SPEED_TO_HA.values())
|
||||
return [all_speeds[0], *all_speeds[1 : number + 1]]
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current room temperature."""
|
||||
return self.unit.room_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self.unit.set_temperature
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return the current HVAC mode."""
|
||||
return (
|
||||
ATA_OPERATION_TO_HVAC_MODE.get(self.unit.operation_mode, HVACMode.OFF)
|
||||
if self.unit.power and self.unit.operation_mode
|
||||
else HVACMode.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
return (
|
||||
ATA_FAN_SPEED_TO_HA.get(self.unit.set_fan_speed)
|
||||
if self.unit.set_fan_speed is not None
|
||||
else None
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.client.control_ata_unit(self._unit_id, power=False)
|
||||
else:
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id,
|
||||
power=True,
|
||||
operation_mode=ATA_HVAC_MODE_TO_OPERATION[hvac_mode],
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the target temperature."""
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id, set_temperature=kwargs[ATTR_TEMPERATURE]
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str:
|
||||
"""Return the current vertical vane direction."""
|
||||
return ATA_VANE_VERTICAL_TO_HA[self.unit.settings["VaneVerticalDirection"]]
|
||||
|
||||
@property
|
||||
def swing_horizontal_mode(self) -> str:
|
||||
"""Return the current horizontal vane direction."""
|
||||
return ATA_VANE_HORIZONTAL_TO_HA[self.unit.settings["VaneHorizontalDirection"]]
|
||||
|
||||
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
||||
"""Set the horizontal vane direction."""
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id,
|
||||
vane_horizontal_direction=HA_VANE_HORIZONTAL_TO_ATA[swing_horizontal_mode],
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set the vertical vane direction."""
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id, vane_vertical_direction=HA_VANE_VERTICAL_TO_ATA[swing_mode]
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id, set_fan_speed=HA_FAN_SPEED_TO_ATA[fan_mode]
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the unit on."""
|
||||
await self.coordinator.client.control_ata_unit(self._unit_id, power=True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the unit off."""
|
||||
await self.coordinator.client.control_ata_unit(self._unit_id, power=False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class ATWZoneClimateEntity(MelCloudHomeATWZoneEntity, ClimateEntity):
|
||||
"""Climate entity for a MELCloud Home ATW zone."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return HVAC modes supported by this zone based on unit capabilities."""
|
||||
modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
if (
|
||||
self.unit.capabilities is None
|
||||
or self.unit.capabilities.has_cooling_mode is not False
|
||||
):
|
||||
modes.append(HVACMode.COOL)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def _zone_mode(self) -> ATWZoneMode | None:
|
||||
"""Return the current ATW zone mode."""
|
||||
if self.zone_number == 1:
|
||||
return self.unit.operation_mode_zone1
|
||||
return self.unit.operation_mode_zone2
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current zone temperature."""
|
||||
return (
|
||||
self.unit.room_temperature_zone1
|
||||
if self.zone_number == 1
|
||||
else self.unit.room_temperature_zone2
|
||||
)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target zone temperature."""
|
||||
return (
|
||||
self.unit.set_temperature_zone1
|
||||
if self.zone_number == 1
|
||||
else self.unit.set_temperature_zone2
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return the current HVAC mode."""
|
||||
return (
|
||||
ATW_ZONE_MODE_TO_HVAC_MODE.get(self._zone_mode, HVACMode.OFF)
|
||||
if self.unit.power and self._zone_mode
|
||||
else HVACMode.OFF
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.client.control_atw_unit(self._unit_id, power=False)
|
||||
else:
|
||||
zone_mode = HVAC_MODE_TO_ATW_ZONE_MODE[hvac_mode]
|
||||
if self.zone_number == 1:
|
||||
await self.coordinator.client.control_atw_unit(
|
||||
self._unit_id,
|
||||
power=True,
|
||||
operation_mode_zone1=zone_mode,
|
||||
)
|
||||
else:
|
||||
await self.coordinator.client.control_atw_unit(
|
||||
self._unit_id,
|
||||
power=True,
|
||||
operation_mode_zone2=zone_mode,
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the target temperature."""
|
||||
temperature = kwargs[ATTR_TEMPERATURE]
|
||||
if self.zone_number == 1:
|
||||
await self.coordinator.client.control_atw_unit(
|
||||
self._unit_id, set_temperature_zone1=temperature
|
||||
)
|
||||
else:
|
||||
await self.coordinator.client.control_atw_unit(
|
||||
self._unit_id, set_temperature_zone2=temperature
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the zone on."""
|
||||
await self.coordinator.client.control_atw_unit(self._unit_id, power=True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the zone off."""
|
||||
await self.coordinator.client.control_atw_unit(self._unit_id, power=False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Config flow for MELCloud Home."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth
|
||||
from aiomelcloudhome.exceptions import (
|
||||
MelCloudHomeAuthenticationError,
|
||||
MelCloudHomeConnectionError,
|
||||
MelCloudHomeTimeoutError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username")
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for MELCloud Home."""
|
||||
|
||||
async def _async_validate_credentials(
|
||||
self, email: str, password: str
|
||||
) -> tuple[dict[str, str], str | None]:
|
||||
"""Validate credentials against MELCloud Home API."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
auth = MelCloudHomeAuth(username=email, password=password, session=session)
|
||||
client = MELCloudHome(auth=auth, session=session)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
user_id: str | None = None
|
||||
|
||||
try:
|
||||
context = await client.get_context()
|
||||
except MelCloudHomeAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except MelCloudHomeConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except MelCloudHomeTimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error while validating MELCloud Home credentials"
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
user_id = context.id
|
||||
|
||||
return errors, user_id
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
errors, user_id = await self._async_validate_credentials(
|
||||
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
if not errors:
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL],
|
||||
data={
|
||||
CONF_EMAIL: user_input[CONF_EMAIL],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Constants for the MELCloud Home integration."""
|
||||
|
||||
DOMAIN = "melcloud_home"
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Coordinator for MELCloud Home."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome, UserContext
|
||||
from aiomelcloudhome.exceptions import (
|
||||
MelCloudHomeAuthenticationError,
|
||||
MelCloudHomeConnectionError,
|
||||
MelCloudHomeTimeoutError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
type MelCloudHomeConfigEntry = ConfigEntry[MelCloudHomeCoordinator]
|
||||
|
||||
|
||||
class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
|
||||
"""Coordinator to manage fetching MELCloud Home data."""
|
||||
|
||||
config_entry: MelCloudHomeConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: MelCloudHomeConfigEntry,
|
||||
client: MELCloudHome,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
self.ata_units: dict[str, ATAUnit] = {}
|
||||
self.atw_units: dict[str, ATWUnit] = {}
|
||||
self.known_ata: set[str] = set()
|
||||
self.known_atw: set[str] = set()
|
||||
self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = []
|
||||
self.new_atw_callbacks: list[Callable[[list[ATWUnit]], None]] = []
|
||||
|
||||
def _notify_new_units(self, data: UserContext) -> None:
|
||||
"""Notify callbacks when new units are discovered."""
|
||||
current_ata = [
|
||||
unit for building in data.buildings for unit in building.air_to_air_units
|
||||
]
|
||||
self.ata_units = {unit.id: unit for unit in current_ata}
|
||||
current_ata_ids = {unit.id for unit in current_ata}
|
||||
self.known_ata &= current_ata_ids
|
||||
new_ata_ids = current_ata_ids - self.known_ata
|
||||
new_ata_units = [unit for unit in current_ata if unit.id in new_ata_ids]
|
||||
if new_ata_units:
|
||||
_LOGGER.debug("Discovered new ATA units: %s", new_ata_units)
|
||||
self.known_ata.update(unit.id for unit in new_ata_units)
|
||||
for ata_callback in self.new_ata_callbacks:
|
||||
ata_callback(new_ata_units)
|
||||
|
||||
current_atw_units = [
|
||||
unit for building in data.buildings for unit in building.air_to_water_units
|
||||
]
|
||||
self.atw_units = {unit.id: unit for unit in current_atw_units}
|
||||
current_atw_ids = {unit.id for unit in current_atw_units}
|
||||
self.known_atw &= current_atw_ids
|
||||
new_atw_ids = current_atw_ids - self.known_atw
|
||||
new_atw_units = [unit for unit in current_atw_units if unit.id in new_atw_ids]
|
||||
if new_atw_units:
|
||||
_LOGGER.debug("Discovered new ATW units: %s", new_atw_units)
|
||||
self.known_atw.update(unit.id for unit in new_atw_units)
|
||||
for atw_callback in self.new_atw_callbacks:
|
||||
atw_callback(new_atw_units)
|
||||
|
||||
async def _async_update_data(self) -> UserContext:
|
||||
"""Fetch data from the MELCloud Home API."""
|
||||
try:
|
||||
data = await self.client.get_context()
|
||||
except MelCloudHomeAuthenticationError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except MelCloudHomeConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except MelCloudHomeTimeoutError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
else:
|
||||
return data
|
||||
|
||||
@callback
|
||||
def _async_refresh_finished(self) -> None:
|
||||
"""Notify entity callbacks after coordinator data has been updated."""
|
||||
if self.data is not None:
|
||||
self._notify_new_units(self.data)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Base entities for MELCloud Home."""
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from aiomelcloudhome import ATAUnit, ATWUnit
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MelCloudHomeCoordinator
|
||||
|
||||
|
||||
class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]):
|
||||
"""Base entity for MELCloud Home."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
|
||||
class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
|
||||
"""Base entity for a MELCloud Home unit."""
|
||||
|
||||
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: _UnitT) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._unit_id = unit.id
|
||||
self._attr_unique_id = unit.id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unit.id)},
|
||||
name=unit.name,
|
||||
manufacturer="Mitsubishi Electric",
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _units_dict(self) -> dict[str, _UnitT]:
|
||||
"""Return the coordinator's units dict keyed by id."""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the entity is available."""
|
||||
return super().available and self._unit_id in self._units_dict()
|
||||
|
||||
@property
|
||||
def unit(self) -> _UnitT:
|
||||
"""Return the current unit state from coordinator data."""
|
||||
return self._units_dict()[self._unit_id]
|
||||
|
||||
|
||||
class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]):
|
||||
"""Base entity for a MELCloud Home Air-to-Air unit."""
|
||||
|
||||
def _units_dict(self) -> dict[str, ATAUnit]:
|
||||
"""Return ATA units dict from coordinator."""
|
||||
return self.coordinator.ata_units
|
||||
|
||||
|
||||
class MelCloudHomeATWUnitEntity(MelCloudHomeUnitEntity[ATWUnit]):
|
||||
"""Base entity for a MELCloud Home Air-to-Water unit."""
|
||||
|
||||
def _units_dict(self) -> dict[str, ATWUnit]:
|
||||
"""Return ATW units dict from coordinator."""
|
||||
return self.coordinator.atw_units
|
||||
|
||||
|
||||
class MelCloudHomeATWZoneEntity(MelCloudHomeATWUnitEntity):
|
||||
"""Base entity for an ATW zone entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
unit: ATWUnit,
|
||||
zone_number: int,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self._zone_number = zone_number
|
||||
self._attr_unique_id = f"{unit.id}_zone_{zone_number}"
|
||||
self._attr_name = f"Zone {zone_number}"
|
||||
|
||||
@property
|
||||
def zone_number(self) -> int:
|
||||
"""Return the zone number."""
|
||||
return self._zone_number
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"forced_hot_water": {
|
||||
"default": "mdi:water-boiler"
|
||||
},
|
||||
"standby": {
|
||||
"default": "mdi:power-sleep"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"room_temperature": {
|
||||
"default": "mdi:home-thermometer"
|
||||
},
|
||||
"room_temperature_zone_1": {
|
||||
"default": "mdi:home-thermometer"
|
||||
},
|
||||
"room_temperature_zone_2": {
|
||||
"default": "mdi:home-thermometer"
|
||||
},
|
||||
"tank_water_temperature": {
|
||||
"default": "mdi:water-thermometer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "melcloud_home",
|
||||
"name": "MELCloud Home",
|
||||
"codeowners": ["@erwindouna"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/melcloud_home",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiomelcloudhome"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiomelcloudhome==0.1.5"]
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No custom actions defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No custom actions defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Coordinator handles polling.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No custom actions defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Sensor platform for MELCloud Home."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiomelcloudhome import ATAUnit, ATWUnit
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ATASensorEntityDescription(SensorEntityDescription):
|
||||
"""Class to hold MELCloud Home ATA sensor description."""
|
||||
|
||||
value_fn: Callable[[ATAUnit], StateType]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ATWSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class to hold MELCloud Home ATW sensor description."""
|
||||
|
||||
value_fn: Callable[[ATWUnit], StateType]
|
||||
exists_fn: Callable[[ATWUnit], bool] = lambda unit: True
|
||||
|
||||
|
||||
ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = (
|
||||
ATASensorEntityDescription(
|
||||
key="room_temperature",
|
||||
translation_key="room_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda unit: unit.room_temperature,
|
||||
),
|
||||
ATASensorEntityDescription(
|
||||
key="rssi",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda unit: unit.rssi,
|
||||
),
|
||||
)
|
||||
|
||||
ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = (
|
||||
ATWSensorEntityDescription(
|
||||
key="room_temperature_zone_1",
|
||||
translation_key="room_temperature_zone_1",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda unit: unit.room_temperature_zone1,
|
||||
),
|
||||
ATWSensorEntityDescription(
|
||||
key="room_temperature_zone_2",
|
||||
translation_key="room_temperature_zone_2",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda unit: unit.room_temperature_zone2,
|
||||
exists_fn=lambda unit: bool(
|
||||
(unit.capabilities and unit.capabilities.has_zone2)
|
||||
or (unit.capabilities is None and unit.has_zone2)
|
||||
),
|
||||
),
|
||||
ATWSensorEntityDescription(
|
||||
key="tank_water_temperature",
|
||||
translation_key="tank_water_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda unit: unit.tank_water_temperature,
|
||||
),
|
||||
ATWSensorEntityDescription(
|
||||
key="rssi",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda unit: unit.rssi,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MelCloudHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MELCloud Home sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATASensor(coordinator, entity_description, unit)
|
||||
for entity_description in ATA_SENSORS
|
||||
for unit in units
|
||||
)
|
||||
|
||||
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATWSensor(coordinator, entity_description, unit)
|
||||
for entity_description in ATW_SENSORS
|
||||
for unit in units
|
||||
if entity_description.exists_fn(unit)
|
||||
)
|
||||
|
||||
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
|
||||
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
|
||||
|
||||
_async_add_new_ata_units(list(coordinator.ata_units.values()))
|
||||
_async_add_new_atw_units(list(coordinator.atw_units.values()))
|
||||
|
||||
|
||||
class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity):
|
||||
"""Representation of a MELCloud Home ATA sensor."""
|
||||
|
||||
entity_description: ATASensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
entity_description: ATASensorEntityDescription,
|
||||
unit: ATAUnit,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.unit)
|
||||
|
||||
|
||||
class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity):
|
||||
"""Representation of a MELCloud Home ATW sensor."""
|
||||
|
||||
entity_description: ATWSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
entity_description: ATWSensorEntityDescription,
|
||||
unit: ATWUnit,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.unit)
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"timeout_connect": "Timeout while communicating with MELCloud Home API",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Email address for your MELCloud Home account.",
|
||||
"password": "Password for your MELCloud Home account."
|
||||
},
|
||||
"description": "Log in to MELCloud Home with the email address and password associated with your account."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"error": {
|
||||
"name": "Error"
|
||||
},
|
||||
"forced_hot_water": {
|
||||
"name": "Forced hot water"
|
||||
},
|
||||
"standby": {
|
||||
"name": "Standby"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"ata_unit": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"speed_1": "Speed 1",
|
||||
"speed_2": "Speed 2",
|
||||
"speed_3": "Speed 3",
|
||||
"speed_4": "Speed 4",
|
||||
"speed_5": "Speed 5"
|
||||
}
|
||||
},
|
||||
"swing_horizontal_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"centre": "Centre",
|
||||
"left": "Left",
|
||||
"left_centre": "Left centre",
|
||||
"right": "Right",
|
||||
"right_centre": "Right centre",
|
||||
"swing": "Swing"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"position_1": "Position 1",
|
||||
"position_2": "Position 2",
|
||||
"position_3": "Position 3",
|
||||
"position_4": "Position 4",
|
||||
"position_5": "Position 5",
|
||||
"swing": "Swing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"room_temperature": {
|
||||
"name": "Room temperature"
|
||||
},
|
||||
"room_temperature_zone_1": {
|
||||
"name": "Zone 1 room temperature"
|
||||
},
|
||||
"room_temperature_zone_2": {
|
||||
"name": "Zone 2 room temperature"
|
||||
},
|
||||
"tank_water_temperature": {
|
||||
"name": "Tank water temperature"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Error communicating with MELCloud Home API: {error}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "An error occurred while trying to authenticate: {error}"
|
||||
},
|
||||
"timeout_connect": {
|
||||
"message": "Timeout while communicating with MELCloud Home API: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user