mirror of
https://github.com/home-assistant/core.git
synced 2026-06-12 20:21:40 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb4f4e46c9 | |||
| 7c9e5ad31b | |||
| 540e9a3d3b | |||
| 11d4b39a9e | |||
| 8379747a5b | |||
| 70a54d333c | |||
| 92b888b11d | |||
| 4b50008c73 | |||
| e4b5fcf539 | |||
| a2bdf4627f | |||
| 42c05f0998 | |||
| ee30356217 | |||
| d8860fc001 | |||
| 0bf27ad6be | |||
| 2e8e5c63e8 | |||
| 99096e4b65 | |||
| 594bcff43f | |||
| e99cb27015 | |||
| 2aa1da7216 | |||
| edbb27a912 | |||
| 4cf5509bc1 | |||
| aa1940095e | |||
| 437d33d791 | |||
| 770488f0d4 | |||
| cefbb109d2 | |||
| d9aa99e338 | |||
| df49891f40 | |||
| 9c86fe2ac5 | |||
| 29badf6651 | |||
| bd58c08eea | |||
| b69c13477a | |||
| ea5e8e7982 | |||
| dfa40f807e | |||
| fdb15ce2d7 | |||
| ee30f6c085 | |||
| d7af8ed2b3 |
+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
|
||||
.github/workflows/*.lock.yml linguist-generated=true merge=ours
|
||||
|
||||
@@ -14,3 +14,4 @@ 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"
|
||||
|
||||
+239
-84
@@ -1,5 +1,5 @@
|
||||
# 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"}]}
|
||||
# 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"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
# | |_| | __ _ ___ _ __ | |_ _ ___
|
||||
@@ -14,7 +14,7 @@
|
||||
# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
|
||||
# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
|
||||
#
|
||||
# This file was automatically generated by gh-aw (v0.74.4). DO NOT EDIT.
|
||||
# This file was automatically generated by gh-aw (v0.79.6). DO NOT EDIT.
|
||||
#
|
||||
# To update this file, edit the corresponding .md file and run:
|
||||
# gh aw compile
|
||||
@@ -36,15 +36,14 @@
|
||||
# - 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@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
# - github/gh-aw-actions/setup@v0.79.6
|
||||
#
|
||||
# Container images used:
|
||||
# - 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
|
||||
# - 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
|
||||
|
||||
name: "Check requirements (AW)"
|
||||
on:
|
||||
@@ -59,15 +58,13 @@ permissions: {}
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
|
||||
run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs:
|
||||
- extract_pr_number
|
||||
- pre_activation
|
||||
needs: pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
@@ -76,9 +73,14 @@ 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 }}
|
||||
@@ -90,33 +92,35 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
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.48"
|
||||
GH_AW_INFO_VERSION: "1.0.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
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 || '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_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_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.25.46"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_AWMG_VERSION: ""
|
||||
GH_AW_INFO_FIREWALL_TYPE: "squid"
|
||||
GH_AW_COMPILED_STRICT: "true"
|
||||
@@ -127,6 +131,24 @@ 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
|
||||
@@ -139,6 +161,7 @@ jobs:
|
||||
sparse-checkout: |
|
||||
.github
|
||||
.agents
|
||||
.antigravity
|
||||
.claude
|
||||
.codex
|
||||
.crush
|
||||
@@ -149,8 +172,8 @@ jobs:
|
||||
fetch-depth: 1
|
||||
- name: Save agent config folders for base branch restoration
|
||||
env:
|
||||
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"
|
||||
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"
|
||||
# poutine:ignore untrusted_checkout_exec
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
|
||||
- name: Check workflow lock file
|
||||
@@ -168,7 +191,7 @@ jobs:
|
||||
- name: Check compile-agentic version
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_COMPILED_VERSION: "v0.74.4"
|
||||
GH_AW_COMPILED_VERSION: "v0.79.6"
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@@ -191,20 +214,20 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -233,12 +256,12 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -306,12 +329,15 @@ 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
|
||||
|
||||
@@ -319,14 +345,15 @@ 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: ""
|
||||
@@ -335,24 +362,27 @@ jobs:
|
||||
GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
GH_AW_WORKFLOW_ID_SANITIZED: checkrequirements
|
||||
outputs:
|
||||
agentic_engine_timeout: ${{ steps.detect-copilot-errors.outputs.agentic_engine_timeout || 'false' }}
|
||||
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 }}
|
||||
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-copilot-errors.outputs.inference_access_error || 'false' }}
|
||||
mcp_policy_error: ${{ steps.detect-copilot-errors.outputs.mcp_policy_error || 'false' }}
|
||||
inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
|
||||
mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
|
||||
model: ${{ needs.activation.outputs.model }}
|
||||
model_not_supported_error: ${{ steps.detect-copilot-errors.outputs.model_not_supported_error || 'false' }}
|
||||
model_not_supported_error: ${{ steps.detect-agent-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@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -361,7 +391,8 @@ 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.48"
|
||||
GH_AW_INFO_VERSION: "1.0.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Set runtime paths
|
||||
id: set-runtime-paths
|
||||
@@ -406,7 +437,7 @@ jobs:
|
||||
- name: Checkout PR branch
|
||||
id: checkout-pr
|
||||
if: |
|
||||
github.event.pull_request || github.event.issue.pull_request
|
||||
github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == '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 }}
|
||||
@@ -418,11 +449,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.48
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
|
||||
env:
|
||||
GH_HOST: github.com
|
||||
- name: Install AWF binary
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.46
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
|
||||
- name: Parse integrity filter lists
|
||||
id: parse-guard-vars
|
||||
env:
|
||||
@@ -438,24 +469,28 @@ jobs:
|
||||
- name: Restore agent config folders from base branch
|
||||
if: steps.checkout-pr.outcome == 'success'
|
||||
env:
|
||||
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"
|
||||
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"
|
||||
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.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
|
||||
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
|
||||
- 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_627e06df80c4e5ad_EOF'
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_f496a449c5dccca1_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_f496a449c5dccca1_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -643,21 +678,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.9'
|
||||
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'
|
||||
|
||||
mkdir -p /home/runner/.copilot
|
||||
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
|
||||
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_f09adf73c5e58a42_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.0.4",
|
||||
"container": "ghcr.io/github/github-mcp-server:v1.1.2",
|
||||
"env": {
|
||||
"GITHUB_HOST": "\${GITHUB_SERVER_URL}",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
|
||||
"GITHUB_READ_ONLY": "1",
|
||||
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
|
||||
"GITHUB_TOOLSETS": "repos,pull_requests"
|
||||
},
|
||||
"guard-policies": {
|
||||
"allow-only": {
|
||||
@@ -691,7 +726,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
GH_AW_MCP_CONFIG_f09adf73c5e58a42_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -720,29 +755,48 @@ 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)
|
||||
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_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"
|
||||
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_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
|
||||
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
|
||||
env:
|
||||
AWF_REFLECT_ENABLED: 1
|
||||
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
|
||||
COPILOT_API_KEY: dummy-byok-key-for-offline-mode
|
||||
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }}
|
||||
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 || '' }}
|
||||
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_VERSION: v0.74.4
|
||||
GH_AW_TIMEOUT_MINUTES: 20
|
||||
GH_AW_VERSION: v0.79.6
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GITHUB_AW: true
|
||||
GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
|
||||
@@ -756,12 +810,13 @@ 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 Copilot errors
|
||||
id: detect-copilot-errors
|
||||
- name: Detect agent errors
|
||||
if: always()
|
||||
id: detect-agent-errors
|
||||
continue-on-error: true
|
||||
run: node "${RUNNER_TEMP}/gh-aw/actions/detect_copilot_errors.cjs"
|
||||
run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
|
||||
- name: Configure Git credentials
|
||||
env:
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
@@ -942,7 +997,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.stale_lock_file_failed == 'true' || needs.activation.outputs.daily_effective_workflow_exceeded == 'true')
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -961,7 +1016,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -970,7 +1025,8 @@ 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.48"
|
||||
GH_AW_INFO_VERSION: "1.0.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Download agent output artifact
|
||||
id: download-agent-output
|
||||
@@ -986,6 +1042,40 @@ 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
|
||||
@@ -993,9 +1083,14 @@ 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: |
|
||||
@@ -1009,6 +1104,7 @@ 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 }}
|
||||
@@ -1026,6 +1122,7 @@ 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: |
|
||||
@@ -1040,6 +1137,7 @@ 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: |
|
||||
@@ -1054,6 +1152,7 @@ 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"
|
||||
@@ -1062,7 +1161,11 @@ 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_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.effective_tokens_rate_limit_error || 'false' }}
|
||||
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_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 }}
|
||||
@@ -1070,12 +1173,14 @@ 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: |
|
||||
@@ -1094,13 +1199,14 @@ 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@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1109,7 +1215,8 @@ 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.48"
|
||||
GH_AW_INFO_VERSION: "1.0.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Download agent output artifact
|
||||
id: download-agent-output
|
||||
@@ -1136,7 +1243,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.25.46 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46 ghcr.io/github/gh-aw-firewall/squid:0.25.46
|
||||
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
|
||||
- name: Check if detection needed
|
||||
id: detection_guard
|
||||
if: always()
|
||||
@@ -1161,7 +1268,11 @@ 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
|
||||
@@ -1195,11 +1306,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.48
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
|
||||
env:
|
||||
GH_HOST: github.com
|
||||
- name: Install AWF binary
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.46
|
||||
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
|
||||
- name: Execute GitHub Copilot CLI
|
||||
if: always() && steps.detection_guard.outputs.run_detection == 'true'
|
||||
continue-on-error: true
|
||||
@@ -1209,27 +1320,46 @@ 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)
|
||||
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_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"
|
||||
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_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
|
||||
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
|
||||
env:
|
||||
AWF_REFLECT_ENABLED: 1
|
||||
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
|
||||
COPILOT_API_KEY: dummy-byok-key-for-offline-mode
|
||||
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || 'claude-sonnet-4.6' }}
|
||||
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 || '' }}
|
||||
GH_AW_PHASE: detection
|
||||
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
|
||||
GH_AW_VERSION: v0.74.4
|
||||
GH_AW_TIMEOUT_MINUTES: 20
|
||||
GH_AW_VERSION: v0.79.6
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GITHUB_AW: true
|
||||
GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
|
||||
@@ -1242,7 +1372,21 @@ 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
|
||||
@@ -1284,6 +1428,7 @@ jobs:
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -1295,6 +1440,7 @@ 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.
|
||||
@@ -1325,14 +1471,15 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
env:
|
||||
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
|
||||
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
|
||||
GH_AW_INFO_VERSION: "1.0.48"
|
||||
GH_AW_INFO_VERSION: "1.0.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Check team membership for workflow
|
||||
id: check_membership
|
||||
@@ -1360,17 +1507,22 @@ jobs:
|
||||
discussions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 45
|
||||
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.48"
|
||||
GH_AW_ENGINE_VERSION: "1.0.60"
|
||||
GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
|
||||
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 }}
|
||||
@@ -1383,7 +1535,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@v0.79.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1392,7 +1544,8 @@ 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.48"
|
||||
GH_AW_INFO_VERSION: "1.0.60"
|
||||
GH_AW_INFO_AWF_VERSION: "v0.27.2"
|
||||
GH_AW_INFO_ENGINE_ID: "copilot"
|
||||
- name: Download agent output artifact
|
||||
id: download-agent-output
|
||||
@@ -1411,6 +1564,7 @@ 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.
|
||||
@@ -1422,6 +1576,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_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,7 +6,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
network:
|
||||
allowed:
|
||||
@@ -14,7 +13,7 @@ network:
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [default, actions]
|
||||
toolsets: [repos, pull_requests]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
@@ -44,7 +43,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.head_sha }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
@@ -83,296 +82,289 @@ description: >
|
||||
|
||||
# Check requirements (AW)
|
||||
|
||||
You are a code review assistant for the Home Assistant project. The
|
||||
deterministic stage has already evaluated every check it can on its own
|
||||
and produced an artifact containing the PR number, per-package check
|
||||
results, and a pre-rendered comment with placeholders. **Your only job is
|
||||
to read that artifact, resolve any `needs_agent` checks, and post the
|
||||
final comment.**
|
||||
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.
|
||||
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
## Step 1 — Read the artifact
|
||||
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
Read the JSON directly for the full schema. Key fields:
|
||||
|
||||
The JSON has this shape:
|
||||
- `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).
|
||||
|
||||
- `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.
|
||||
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.
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
|
||||
For each `package` in `packages`:
|
||||
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 `(check_kind, result)` in `package.checks` where
|
||||
`result.status == "needs_agent"`:
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
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:
|
||||
❌ Internal error: deterministic artifact contains an unknown check kind
|
||||
(`<check_kind>` on `<pkg>`).
|
||||
```
|
||||
|
||||
```
|
||||
<!-- 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.
|
||||
Then stop. Do not improvise a verdict.
|
||||
|
||||
## Step 3 — Post the comment
|
||||
|
||||
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
|
||||
in `rendered_comment` with the resolved value.
|
||||
2. Emit the resulting markdown using `add_comment` — set `body` to the
|
||||
merged `rendered_comment` verbatim (the leading
|
||||
`<!-- requirements-check -->` marker must be preserved). The PR
|
||||
target is already set by the workflow; do not pass `item_number`.
|
||||
|
||||
If the artifact's top-level `needs_agent` is `false` (no checks need
|
||||
you), emit `rendered_comment` unchanged.
|
||||
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`.
|
||||
|
||||
## Check instructions
|
||||
|
||||
### Check kind: `repo_public`
|
||||
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
`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.
|
||||
|
||||
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.
|
||||
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.`.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
Verify the PR description contains the right link for the change.
|
||||
Fetch the PR body via the `pull_requests` MCP using `pr_number`. Extract URLs.
|
||||
|
||||
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>.`
|
||||
- **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>.`
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
Inspect the upstream's publish-to-PyPI CI. Host-specific lookup, same
|
||||
rubric:
|
||||
|
||||
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.`
|
||||
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.
|
||||
|
||||
### Check kind: `async_blocking`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
**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.
|
||||
|
||||
- `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.
|
||||
**Step 1 — async surface?**
|
||||
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
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.
|
||||
|
||||
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}`).
|
||||
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.)
|
||||
|
||||
- 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.
|
||||
**Step 2 — review the surface**
|
||||
|
||||
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.*
|
||||
- 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.
|
||||
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
**Blocking patterns to flag inside `async def`:**
|
||||
|
||||
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`,
|
||||
- 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`,
|
||||
blocking `select.select`.
|
||||
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
|
||||
non-trivial sizes (small one-shot reads during import are
|
||||
acceptable; reads/writes on the request path are not — prefer
|
||||
`aiofiles` / executor).
|
||||
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
|
||||
`pymongo` (sync client), `redis.Redis` (sync client).
|
||||
- `subprocess.run` / `subprocess.call` / `os.system` (must be
|
||||
`asyncio.create_subprocess_*`).
|
||||
- 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`.
|
||||
|
||||
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.
|
||||
Calls dispatched to an executor (`run_in_executor`,
|
||||
`asyncio.to_thread`, `anyio.to_thread.run_sync`) do **not** count as
|
||||
blocking.
|
||||
|
||||
#### Step 4 — Verdict
|
||||
**Verdict:**
|
||||
|
||||
- ✅ — 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.
|
||||
- ✅ — 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.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive and helpful. Reference the inspected workflow / CI
|
||||
file by URL where useful so the contributor can fix the issue.
|
||||
- The dedup of the requirements-check comment is handled by gh-aw's
|
||||
`add_comment` safe-output via the `<!-- requirements-check -->`
|
||||
marker on the first line of `rendered_comment`.
|
||||
- If the deterministic workflow concluded with a non-success status,
|
||||
this workflow's `if:` guard on `Download deterministic-results
|
||||
artifact` skipped the download. If you find no file at
|
||||
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
|
||||
verification is also gated and will not complain.
|
||||
- Be constructive; 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.15
|
||||
rev: v0.15.16
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -31,7 +31,7 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -144,7 +144,9 @@ def _sensor_device_info_to_hass(
|
||||
adv: Aranet4Advertisement,
|
||||
) -> DeviceInfo:
|
||||
"""Convert a sensor device info to hass device info."""
|
||||
hass_device_info = DeviceInfo({})
|
||||
hass_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, adv.device.address)}
|
||||
)
|
||||
if adv.readings and adv.readings.name:
|
||||
hass_device_info[ATTR_NAME] = adv.readings.name
|
||||
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["mozart-api==5.3.1.108.2"],
|
||||
"requirements": ["mozart-api==6.2.0.44.0"],
|
||||
"zeroconf": ["_bangolufsen._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ BINARY_SENSOR_TYPES = (
|
||||
key="open",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="input",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -56,6 +59,8 @@ 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,6 +41,8 @@ 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,6 +51,7 @@ 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
|
||||
|
||||
@@ -74,6 +74,8 @@ 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:
|
||||
|
||||
@@ -12,11 +12,12 @@ 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(
|
||||
|
||||
@@ -71,6 +71,11 @@ 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:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -67,6 +68,7 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="temperature",
|
||||
translation_key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -97,48 +99,56 @@ 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",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
translation_key="reactive_power",
|
||||
device_class=SensorDeviceClass.REACTIVE_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,
|
||||
@@ -172,10 +182,20 @@ 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)
|
||||
for feature in coordinator.box.features.get("sensors", [])
|
||||
BleBoxSensorEntity(
|
||||
coordinator,
|
||||
feature,
|
||||
description,
|
||||
feature.index
|
||||
if counts[feature.device_class] > 1 and feature.index
|
||||
else None,
|
||||
)
|
||||
for feature in features
|
||||
for description in SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
@@ -192,10 +212,16 @@ 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,13 +30,24 @@
|
||||
"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": {
|
||||
@@ -49,7 +60,14 @@
|
||||
"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%]",
|
||||
@@ -57,7 +75,16 @@
|
||||
"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,6 +9,7 @@ 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
|
||||
|
||||
@@ -34,6 +35,16 @@ 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."""
|
||||
|
||||
@@ -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 to the To-do list.
|
||||
"""Update an item in 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.2"]
|
||||
"requirements": ["bthome-ble==3.23.4"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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,6 +31,29 @@ 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(
|
||||
@@ -40,8 +63,15 @@ 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(
|
||||
config_entries[0]
|
||||
"write_requests_remaining": _async_get_write_requests_remaining_summary(
|
||||
config_entries
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.5"]
|
||||
"requirements": ["home-assistant-frontend==20260527.6"]
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
|
||||
entity_description = LightEntityDescription(
|
||||
key="hue_grouped_light",
|
||||
translation_key="hue_grouped_light",
|
||||
has_entity_name=True,
|
||||
name=None,
|
||||
)
|
||||
|
||||
@@ -166,8 +166,10 @@ class HueLightLevelSensor(HueSensorBase):
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
def native_value(self) -> int | None:
|
||||
"""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,6 +7,7 @@ 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]
|
||||
@@ -19,6 +20,7 @@ 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,18 +4,29 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hyponcloud import AuthenticationError, HyponCloud
|
||||
from hyponcloud import KNOWN_OEMS, AdminInfo, 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 DOMAIN
|
||||
from .const import CONF_OEM, DEFAULT_OEM, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
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(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
@@ -23,52 +34,128 @@ STEP_USER_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:
|
||||
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)
|
||||
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])
|
||||
)
|
||||
if self.source == SOURCE_USER:
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME],
|
||||
data=user_input,
|
||||
title=entry_data[CONF_USERNAME],
|
||||
data=entry_data,
|
||||
)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_USERNAME: entry_data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry_data[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
step_id="user", data_schema=_data_schema(default_oem), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
return await self.async_step_user()
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -4,4 +4,7 @@ from logging import Logger, getLogger
|
||||
|
||||
DOMAIN = "hypontech"
|
||||
|
||||
CONF_OEM = "oem"
|
||||
DEFAULT_OEM = 0
|
||||
|
||||
LOGGER: Logger = getLogger(__package__)
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from hyponcloud import (
|
||||
KNOWN_OEMS,
|
||||
HyponCloud,
|
||||
OverviewData,
|
||||
PlantData,
|
||||
@@ -16,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import CONF_OEM, DEFAULT_OEM, DOMAIN, LOGGER
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -37,6 +38,8 @@ 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."""
|
||||
@@ -60,6 +63,7 @@ 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="Hypontech",
|
||||
manufacturer=coordinator.oem_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class HypontechPlantEntity(CoordinatorEntity[HypontechDataCoordinator]):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, plant_id)},
|
||||
name=plant.info.plant_name,
|
||||
manufacturer="Hypontech",
|
||||
manufacturer=coordinator.oem_name,
|
||||
model=plant.info.plant_type,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
},
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ 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)
|
||||
|
||||
@@ -68,4 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
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
|
||||
|
||||
@@ -40,6 +40,8 @@ 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,
|
||||
@@ -69,6 +71,35 @@ 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,
|
||||
@@ -84,30 +115,17 @@ def setup_platform(
|
||||
_LOGGER.error("Unable to find iTach")
|
||||
return
|
||||
|
||||
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))
|
||||
devices = [
|
||||
_setup_remote_entity(itachip2ir, device_config)
|
||||
for device_config in config[CONF_DEVICES]
|
||||
]
|
||||
add_entities(devices, True)
|
||||
|
||||
|
||||
class ITachIP2IRRemote(remote.RemoteEntity):
|
||||
"""Device that sends commands to an ITachIP2IR device."""
|
||||
|
||||
def __init__(self, itachip2ir, name, ir_count):
|
||||
def __init__(self, itachip2ir: Any, name: str | None, ir_count: int) -> None:
|
||||
"""Initialize device."""
|
||||
self.itachip2ir = itachip2ir
|
||||
self._attr_is_on = False
|
||||
|
||||
@@ -4,7 +4,14 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic
|
||||
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
|
||||
from pylitterbot import (
|
||||
FeederRobot,
|
||||
LitterRobot,
|
||||
LitterRobot3,
|
||||
LitterRobot4,
|
||||
LitterRobot5,
|
||||
Robot,
|
||||
)
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -60,6 +67,41 @@ 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,6 +41,12 @@ 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,9 +1,21 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
@@ -12,6 +24,9 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"change_filter": {
|
||||
"default": "mdi:filter-cog"
|
||||
},
|
||||
"give_snack": {
|
||||
"default": "mdi:candy-outline"
|
||||
},
|
||||
@@ -65,6 +80,12 @@
|
||||
"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,6 +166,22 @@ 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,9 +45,21 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -59,6 +71,9 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"change_filter": {
|
||||
"name": "Change filter"
|
||||
},
|
||||
"give_snack": {
|
||||
"name": "Give snack"
|
||||
},
|
||||
@@ -132,9 +147,16 @@
|
||||
"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,9 +422,7 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
"Invalid alarm code provided",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_code",
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""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)
|
||||
@@ -24,6 +24,8 @@ 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,
|
||||
@@ -103,7 +105,6 @@ async def async_setup_entry(
|
||||
async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units)
|
||||
|
||||
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
|
||||
# Erwin: create zone 1 for all units, and zone 2 only when the unit supports it.
|
||||
async_add_entities(
|
||||
ATWZoneClimateEntity(coordinator, unit, zone_number)
|
||||
for unit in units
|
||||
@@ -186,12 +187,12 @@ class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current room temperature."""
|
||||
return self.unit.room_temperature if self.unit else None
|
||||
return self.unit.room_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self.unit.set_temperature if self.unit else None
|
||||
return self.unit.set_temperature
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
|
||||
@@ -26,7 +26,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_EMAIL): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username")
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
|
||||
@@ -15,7 +15,6 @@ class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]):
|
||||
"""Base entity for MELCloud Home."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name: str | None = None
|
||||
|
||||
|
||||
class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ rules:
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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)
|
||||
@@ -19,11 +19,22 @@
|
||||
"email": "Email address for your MELCloud Home account.",
|
||||
"password": "Password for your MELCloud Home account."
|
||||
},
|
||||
"description": "Login to MELCloud Home with the email address and password associated with your 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": {
|
||||
@@ -61,6 +72,20 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -62,13 +62,15 @@ def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None:
|
||||
return None
|
||||
if len(raw) != 32:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Stored OpenDisplay encryption key is invalid; reauthentication required"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_error",
|
||||
)
|
||||
try:
|
||||
return bytes.fromhex(raw)
|
||||
except ValueError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Stored OpenDisplay encryption key is invalid; reauthentication required"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_error",
|
||||
) from err
|
||||
|
||||
|
||||
@@ -108,11 +110,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry)
|
||||
is_flex = device.is_flex
|
||||
except (AuthenticationFailedError, AuthenticationRequiredError) as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Encryption key rejected by OpenDisplay device: {err}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_error",
|
||||
) from err
|
||||
except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Failed to connect to OpenDisplay device: {err}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_connection_error",
|
||||
) from err
|
||||
device_config = device.config
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -57,7 +57,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
"media_download_error": {
|
||||
"message": "Failed to download media: {error}"
|
||||
},
|
||||
"setup_connection_error": {
|
||||
"message": "Failed to connect to OpenDisplay device."
|
||||
},
|
||||
"upload_error": {
|
||||
"message": "Failed to upload image to the display."
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["openevsehttp"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-openevse-http==1.0.1"],
|
||||
"zeroconf": ["_openevse._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -52,5 +52,5 @@ class OpenGarageEntity(CoordinatorEntity[OpenGarageDataUpdateCoordinator]):
|
||||
manufacturer="Open Garage",
|
||||
name=self.coordinator.data["name"],
|
||||
suggested_area="Garage",
|
||||
sw_version=self.coordinator.data["fwv"],
|
||||
sw_version=str(self.coordinator.data["fwv"]),
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]):
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)},
|
||||
name=self._data.controller.name.capitalize(),
|
||||
manufacturer="RainMachine",
|
||||
hw_version=self._version_coordinator.data["hwVer"],
|
||||
hw_version=str(self._version_coordinator.data["hwVer"]),
|
||||
sw_version=f"{self._version_coordinator.data['swVer']} "
|
||||
f"(API: {self._version_coordinator.data['apiVer']})",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Base class for Rituals Perfume Genie diffuser entity."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -12,6 +14,12 @@ MODEL = "The Perfume Genie"
|
||||
MODEL2 = "The Perfume Genie 2.0"
|
||||
|
||||
|
||||
def _version_string(version: Any) -> str:
|
||||
if isinstance(version, dict):
|
||||
return str(version.get("title", version))
|
||||
return str(version)
|
||||
|
||||
|
||||
class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]):
|
||||
"""Representation of a diffuser entity."""
|
||||
|
||||
@@ -31,7 +39,7 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]):
|
||||
manufacturer=MANUFACTURER,
|
||||
model=MODEL if coordinator.diffuser.has_battery else MODEL2,
|
||||
name=coordinator.diffuser.name,
|
||||
sw_version=coordinator.diffuser.version,
|
||||
sw_version=_version_string(coordinator.diffuser.version),
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -287,13 +287,9 @@ class DatasetStore:
|
||||
entry: DatasetEntry | None
|
||||
for entry in self.datasets.values():
|
||||
if entry.dataset == dataset:
|
||||
if (
|
||||
preferred_extended_address
|
||||
and entry.preferred_extended_address is None
|
||||
):
|
||||
self.async_set_preferred_border_agent(
|
||||
entry.id, preferred_border_agent_id, preferred_extended_address
|
||||
)
|
||||
self._async_maybe_update_preferred_border_agent(
|
||||
entry, preferred_border_agent_id, preferred_extended_address
|
||||
)
|
||||
return
|
||||
|
||||
# Update if dataset with same extended pan id exists and the timestamp
|
||||
@@ -341,10 +337,9 @@ class DatasetStore:
|
||||
self.datasets[entry.id], tlv=tlv
|
||||
)
|
||||
self.async_schedule_save()
|
||||
if preferred_extended_address and entry.preferred_extended_address is None:
|
||||
self.async_set_preferred_border_agent(
|
||||
entry.id, preferred_border_agent_id, preferred_extended_address
|
||||
)
|
||||
self._async_maybe_update_preferred_border_agent(
|
||||
entry, preferred_border_agent_id, preferred_extended_address
|
||||
)
|
||||
return
|
||||
|
||||
entry = DatasetEntry(
|
||||
@@ -382,6 +377,37 @@ class DatasetStore:
|
||||
"""Get dataset by id."""
|
||||
return self.datasets.get(dataset_id)
|
||||
|
||||
@callback
|
||||
def _async_maybe_update_preferred_border_agent(
|
||||
self,
|
||||
entry: DatasetEntry,
|
||||
preferred_border_agent_id: str | None,
|
||||
preferred_extended_address: str | None,
|
||||
) -> None:
|
||||
"""Update the preferred border agent of an existing dataset if appropriate.
|
||||
|
||||
Sets the preferred border agent if it was not set yet, or refreshes the
|
||||
stored extended address when the border agent ID still matches but the
|
||||
extended address changed. The latter happens e.g. after an OTBR upgrade
|
||||
regenerates the extended address while keeping the same border agent ID.
|
||||
"""
|
||||
if not preferred_extended_address:
|
||||
return
|
||||
if entry.preferred_extended_address is None or (
|
||||
preferred_border_agent_id is not None
|
||||
and preferred_border_agent_id == entry.preferred_border_agent_id
|
||||
and preferred_extended_address != entry.preferred_extended_address
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Updating extended address of preferred border agent %s from %s to %s",
|
||||
preferred_border_agent_id,
|
||||
entry.preferred_extended_address,
|
||||
preferred_extended_address,
|
||||
)
|
||||
self.async_set_preferred_border_agent(
|
||||
entry.id, preferred_border_agent_id, preferred_extended_address
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_preferred_border_agent(
|
||||
self, dataset_id: str, border_agent_id: str | None, extended_address: str
|
||||
|
||||
@@ -440,7 +440,7 @@ class ProtectSettableKeysMixin(ProtectEntityDescription[T]):
|
||||
|
||||
async def ufp_set(self, obj: T, value: Any) -> None:
|
||||
"""Set value for UniFi Protect device."""
|
||||
_LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.display_name)
|
||||
_LOGGER.debug("Setting %s to %s for %s", self.key, value, obj.display_name)
|
||||
if self.ufp_set_method is not None:
|
||||
await getattr(obj, self.ufp_set_method)(value)
|
||||
elif self.ufp_set_method_fn is not None:
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
from .const import DOMAIN
|
||||
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Binary sensor platform for the Yoto integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from yoto_api import YotoPlayer
|
||||
|
||||
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 YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
from .entity import YotoEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class YotoBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes a Yoto binary sensor entity."""
|
||||
|
||||
is_on_fn: Callable[[YotoPlayer], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[YotoBinarySensorEntityDescription, ...] = (
|
||||
YotoBinarySensorEntityDescription(
|
||||
key="charging",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
is_on_fn=lambda player: player.status.is_charging,
|
||||
),
|
||||
YotoBinarySensorEntityDescription(
|
||||
key="headphones",
|
||||
translation_key="headphones",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
is_on_fn=lambda player: player.status.is_audio_device_connected,
|
||||
),
|
||||
YotoBinarySensorEntityDescription(
|
||||
key="bluetooth_audio",
|
||||
translation_key="bluetooth_audio",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
is_on_fn=lambda player: player.status.is_bluetooth_audio_connected,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: YotoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Yoto binary sensor platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
YotoBinarySensor(coordinator, player, description)
|
||||
for player in coordinator.client.players.values()
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
|
||||
|
||||
class YotoBinarySensor(YotoEntity, BinarySensorEntity):
|
||||
"""Representation of a Yoto player binary sensor."""
|
||||
|
||||
entity_description: YotoBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: YotoDataUpdateCoordinator,
|
||||
player: YotoPlayer,
|
||||
description: YotoBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator, player)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{player.id}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the binary sensor state."""
|
||||
return self.entity_description.is_on_fn(self.player)
|
||||
@@ -43,4 +43,8 @@ class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the entity is available."""
|
||||
return super().available and self._player_id in self.coordinator.data
|
||||
return (
|
||||
super().available
|
||||
and self._player_id in self.coordinator.data
|
||||
and bool(self.player.is_online)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth_audio": {
|
||||
"default": "mdi:bluetooth-audio"
|
||||
},
|
||||
"headphones": {
|
||||
"default": "mdi:headphones"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["yoto_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["yoto-api==4.0.2"]
|
||||
"requirements": ["yoto-api==4.1.0"]
|
||||
}
|
||||
|
||||
@@ -82,11 +82,6 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
super().__init__(coordinator, player)
|
||||
self._attr_unique_id = player.id
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether the player is reachable through the Yoto cloud."""
|
||||
return super().available and bool(self.player.is_online)
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the playback state."""
|
||||
|
||||
@@ -57,20 +57,14 @@ rules:
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: Only the media_player entity ships in this PR; no diagnostic entities yet.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Only the media_player entity ships in this PR; no entities are disabled by default.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: The media_player uses the device name; no translatable strings yet.
|
||||
comment: No noisy or less popular entities; nothing is disabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: No custom icon translations are needed yet.
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: Authorization is the only configuration; reauth covers re-linking the account.
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth_audio": {
|
||||
"name": "Bluetooth audio"
|
||||
},
|
||||
"headphones": {
|
||||
"name": "Headphones"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Yoto credentials are no longer valid. Please reauthenticate your account."
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==6.8.3
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.7.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
home-assistant-frontend==20260527.5
|
||||
home-assistant-frontend==20260527.6
|
||||
home-assistant-intents==2026.6.1
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
|
||||
|
||||
from typing import Final
|
||||
|
||||
FRONTEND_VERSION: Final[str] = "20260527.5"
|
||||
FRONTEND_VERSION: Final[str] = "20260527.6"
|
||||
|
||||
MDI_ICONS: Final[set[str]] = {
|
||||
"ab-testing",
|
||||
|
||||
+1
-1
@@ -648,7 +648,7 @@ exclude_lines = [
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
required-version = ">=0.15.15"
|
||||
required-version = ">=0.15.16"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
|
||||
Generated
+4
-4
@@ -724,7 +724,7 @@ brottsplatskartan==1.0.5
|
||||
brunt==1.2.0
|
||||
|
||||
# homeassistant.components.bthome
|
||||
bthome-ble==3.23.2
|
||||
bthome-ble==3.23.4
|
||||
|
||||
# homeassistant.components.bt_home_hub_5
|
||||
bthomehub5-devicelist==0.1.1
|
||||
@@ -1272,7 +1272,7 @@ hole==0.9.0
|
||||
holidays==0.98
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260527.5
|
||||
home-assistant-frontend==20260527.6
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.6.1
|
||||
@@ -1610,7 +1610,7 @@ motionblindsble==0.1.3
|
||||
motioneye-client==0.3.14
|
||||
|
||||
# homeassistant.components.bang_olufsen
|
||||
mozart-api==5.3.1.108.2
|
||||
mozart-api==6.2.0.44.0
|
||||
|
||||
# homeassistant.components.mullvad
|
||||
mullvad-api==1.0.0
|
||||
@@ -3436,7 +3436,7 @@ yeelightsunflower==0.0.10
|
||||
yolink-api==0.6.5
|
||||
|
||||
# homeassistant.components.yoto
|
||||
yoto-api==4.0.2
|
||||
yoto-api==4.1.0
|
||||
|
||||
# homeassistant.components.youless
|
||||
youless-api==2.2.0
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
|
||||
|
||||
codespell==2.4.2
|
||||
ruff==0.15.15
|
||||
ruff==0.15.16
|
||||
yamllint==1.38.0
|
||||
zizmor==1.24.1
|
||||
|
||||
@@ -25,6 +25,7 @@ class CheckKind(StrEnum):
|
||||
REPO_PUBLIC = "repo_public"
|
||||
CI_UPLOAD = "ci_upload"
|
||||
RELEASE_PIPELINE = "release_pipeline"
|
||||
SECURITY = "security"
|
||||
PR_LINK = "pr_link"
|
||||
ASYNC_BLOCKING = "async_blocking"
|
||||
YANKED = "yanked"
|
||||
|
||||
@@ -21,6 +21,7 @@ _CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
|
||||
(CheckKind.REPO_PUBLIC, "Repo Public"),
|
||||
(CheckKind.CI_UPLOAD, "CI Upload"),
|
||||
(CheckKind.RELEASE_PIPELINE, "Release Pipeline"),
|
||||
(CheckKind.SECURITY, "Security"),
|
||||
(CheckKind.PR_LINK, "PR Link"),
|
||||
(CheckKind.ASYNC_BLOCKING, "Async Safe"),
|
||||
)
|
||||
|
||||
@@ -16,6 +16,8 @@ What the runner defers to the LLM (NEEDS_AGENT):
|
||||
- `async_blocking`: inspection of the dependency source for blocking I/O
|
||||
inside `async def` functions. Always deferred when the source repo is
|
||||
available — the deterministic stage cannot read the upstream source.
|
||||
- `security`: lightweight scan of the upstream source for supply-chain red
|
||||
flags. Always deferred — the agent fetches the source and inspects it.
|
||||
"""
|
||||
|
||||
from .diff import parse_diff
|
||||
@@ -137,6 +139,7 @@ def run_checks(
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = fail
|
||||
pkg.checks[CheckKind.PR_LINK] = fail
|
||||
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
|
||||
pkg.checks[CheckKind.SECURITY] = fail
|
||||
elif pkg.repo_url:
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = CheckResult(
|
||||
CheckStatus.NEEDS_AGENT,
|
||||
@@ -146,6 +149,10 @@ def run_checks(
|
||||
CheckStatus.NEEDS_AGENT,
|
||||
"Presence of the required link in the PR description must be verified by the agent.",
|
||||
)
|
||||
pkg.checks[CheckKind.SECURITY] = CheckResult(
|
||||
CheckStatus.NEEDS_AGENT,
|
||||
"Baseline supply-chain source scan must be performed by the agent.",
|
||||
)
|
||||
if pkg.old_version is None:
|
||||
async_reason = (
|
||||
"New dependency: agent must review the entire source tree "
|
||||
@@ -168,6 +175,10 @@ def run_checks(
|
||||
pkg.checks[CheckKind.REPO_PUBLIC] = fail
|
||||
pkg.checks[CheckKind.PR_LINK] = fail
|
||||
pkg.checks[CheckKind.ASYNC_BLOCKING] = fail
|
||||
pkg.checks[CheckKind.SECURITY] = CheckResult(
|
||||
CheckStatus.FAIL,
|
||||
"No source repository URL on PyPI — source cannot be inspected.",
|
||||
)
|
||||
result = CheckRunResult(pr_number=pr_number, packages=packages)
|
||||
result.rendered_comment = render_comment(result)
|
||||
return result
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""Test the Aqvify init."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pyaqvify import AqvifyAuthException
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
async def test_load_unload_entry(
|
||||
@@ -38,8 +42,9 @@ async def test_load_unload_entry(
|
||||
(None, ConfigEntryState.LOADED),
|
||||
(AqvifyAuthException, ConfigEntryState.SETUP_ERROR),
|
||||
(TimeoutError, ConfigEntryState.SETUP_RETRY),
|
||||
(ClientResponseError(Mock(), Mock(), status=500), ConfigEntryState.SETUP_RETRY),
|
||||
],
|
||||
ids=["no_error", "auth_error", "timeout_error"],
|
||||
ids=["no_error", "auth_error", "timeout_error", "communications_error"],
|
||||
)
|
||||
async def test_setup_entry_with_error(
|
||||
hass: HomeAssistant,
|
||||
@@ -93,3 +98,104 @@ async def test_setup_entry_auth_error_triggers_reauth(
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert len(flows) == 1
|
||||
assert flows[0]["step_id"] == "reauth_confirm"
|
||||
|
||||
|
||||
WATER_LEVEL_SENSOR = "sensor.device_1_water_level"
|
||||
EXPECTED_WATER_LEVEL = "-0.136786005"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "log_message", "expected_state"),
|
||||
[
|
||||
(
|
||||
TimeoutError,
|
||||
"Timeout occurred while communicating",
|
||||
EXPECTED_WATER_LEVEL,
|
||||
),
|
||||
(
|
||||
ClientResponseError(Mock(), Mock(), status=500),
|
||||
"An error occurred while communicating",
|
||||
EXPECTED_WATER_LEVEL,
|
||||
),
|
||||
(
|
||||
AqvifyAuthException,
|
||||
"Authentication failed",
|
||||
"unavailable",
|
||||
),
|
||||
],
|
||||
ids=["timeout_error", "communications_error", "auth_error"],
|
||||
)
|
||||
async def test_coordinator_get_devices_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_aqvify_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
exception: Exception,
|
||||
log_message: str,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
"""Tests that the coordinator handles errors from async_get_devices."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_aqvify_client.async_get_devices.side_effect = exception
|
||||
|
||||
caplog.clear()
|
||||
freezer.tick(delta=timedelta(hours=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(WATER_LEVEL_SENSOR).state == STATE_UNAVAILABLE
|
||||
assert log_message in caplog.text
|
||||
|
||||
mock_aqvify_client.async_get_devices.side_effect = None
|
||||
freezer.tick(delta=timedelta(hours=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(WATER_LEVEL_SENSOR).state == expected_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "log_message", "expected_state"),
|
||||
[
|
||||
(TimeoutError, "Timeout occurred while communicating", EXPECTED_WATER_LEVEL),
|
||||
(
|
||||
ClientResponseError(Mock(), Mock(), status=500),
|
||||
"An error occurred while communicating",
|
||||
EXPECTED_WATER_LEVEL,
|
||||
),
|
||||
(AqvifyAuthException, "Invalid API key.", "unavailable"),
|
||||
],
|
||||
ids=["timeout_error", "communications_error", "auth_error"],
|
||||
)
|
||||
async def test_coordinator_get_device_data_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_aqvify_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
exception: Exception,
|
||||
log_message: str,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
"""Tests that the coordinator handles errors from async_get_device_latest_data."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_aqvify_client.async_get_device_latest_data.side_effect = exception
|
||||
|
||||
caplog.clear()
|
||||
freezer.tick(delta=timedelta(hours=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(WATER_LEVEL_SENSOR).state == STATE_UNAVAILABLE
|
||||
assert log_message in caplog.text
|
||||
mock_aqvify_client.async_get_device_latest_data.side_effect = None
|
||||
freezer.tick(delta=timedelta(hours=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(WATER_LEVEL_SENSOR).state == expected_state
|
||||
|
||||
@@ -85,6 +85,7 @@ async def test_sensors_aranet_radiation(
|
||||
assert device.model == "Aranet Radiation"
|
||||
assert device.sw_version == "v1.4.38"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -146,6 +147,7 @@ async def test_sensors_aranet2(
|
||||
assert device.model == "Aranet2"
|
||||
assert device.sw_version == "v1.4.4"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -227,6 +229,7 @@ async def test_sensors_aranet4(
|
||||
assert device.model == "Aranet4"
|
||||
assert device.sw_version == "v1.2.0"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -310,6 +313,7 @@ async def test_sensors_aranetrn(
|
||||
assert device.model == "Aranet Radon"
|
||||
assert device.sw_version == "v1.6.4"
|
||||
assert device.manufacturer == "SAF Tehnika"
|
||||
assert device.connections == {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from uuid import UUID
|
||||
|
||||
from mozart_api.models import (
|
||||
Action,
|
||||
@@ -254,8 +255,8 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
dynamic_list=None,
|
||||
first_child_menu_item_id=None,
|
||||
label="Yle Radio Suomi Helsinki",
|
||||
next_sibling_menu_item_id="0b4552f8-7ac6-5046-9d44-5410a815b8d6",
|
||||
parent_menu_item_id="eee0c2d0-2b3a-4899-a708-658475c38926",
|
||||
next_sibling_menu_item_id=UUID("0b4552f8-7ac6-5046-9d44-5410a815b8d6"),
|
||||
parent_menu_item_id=UUID("eee0c2d0-2b3a-4899-a708-658475c38926"),
|
||||
available=None,
|
||||
content=ContentItem(
|
||||
categories=["music"],
|
||||
@@ -264,7 +265,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
source=SourceTypeEnum(value="netRadio"),
|
||||
),
|
||||
fixed=True,
|
||||
id="b355888b-2cde-5f94-8592-d47b71d52a27",
|
||||
id=UUID("b355888b-2cde-5f94-8592-d47b71d52a27"),
|
||||
),
|
||||
# Has "hdmi" as category, so should be included in video sources
|
||||
"b6591565-80f4-4356-bcd9-c92ca247f0a9": RemoteMenuItem(
|
||||
@@ -293,8 +294,8 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
dynamic_list="none",
|
||||
first_child_menu_item_id=None,
|
||||
label="HDMI A",
|
||||
next_sibling_menu_item_id="0ba98974-7b1f-40dc-bc48-fbacbb0f1793",
|
||||
parent_menu_item_id="b66c835b-6b98-4400-8f84-6348043792c7",
|
||||
next_sibling_menu_item_id=UUID("0ba98974-7b1f-40dc-bc48-fbacbb0f1793"),
|
||||
parent_menu_item_id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"),
|
||||
available=True,
|
||||
content=ContentItem(
|
||||
categories=["hdmi"],
|
||||
@@ -303,7 +304,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
source=SourceTypeEnum(value="tv"),
|
||||
),
|
||||
fixed=False,
|
||||
id="b6591565-80f4-4356-bcd9-c92ca247f0a9",
|
||||
id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"),
|
||||
),
|
||||
# The parent remote menu item. Has the TV label and
|
||||
# should therefore not be included in video sources
|
||||
@@ -312,14 +313,14 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
scene_list=None,
|
||||
disabled=False,
|
||||
dynamic_list="none",
|
||||
first_child_menu_item_id="b6591565-80f4-4356-bcd9-c92ca247f0a9",
|
||||
first_child_menu_item_id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"),
|
||||
label="TV",
|
||||
next_sibling_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb",
|
||||
next_sibling_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"),
|
||||
parent_menu_item_id=None,
|
||||
available=True,
|
||||
content=None,
|
||||
fixed=True,
|
||||
id="b66c835b-6b98-4400-8f84-6348043792c7",
|
||||
id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"),
|
||||
),
|
||||
# Has an empty content, so should not be included
|
||||
"64c9da45-3682-44a4-8030-09ed3ef44160": RemoteMenuItem(
|
||||
@@ -330,11 +331,11 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
first_child_menu_item_id=None,
|
||||
label="ListeningPosition",
|
||||
next_sibling_menu_item_id=None,
|
||||
parent_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb",
|
||||
parent_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"),
|
||||
available=True,
|
||||
content=None,
|
||||
fixed=True,
|
||||
id="64c9da45-3682-44a4-8030-09ed3ef44160",
|
||||
id=UUID("64c9da45-3682-44a4-8030-09ed3ef44160"),
|
||||
),
|
||||
}
|
||||
client.get_beolink_peers = AsyncMock()
|
||||
@@ -343,11 +344,13 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
friendly_name=TEST_FRIENDLY_NAME_3,
|
||||
jid=TEST_JID_3,
|
||||
ip_address=TEST_HOST_3,
|
||||
audio_transport="v2",
|
||||
),
|
||||
BeolinkPeer(
|
||||
friendly_name=TEST_FRIENDLY_NAME_4,
|
||||
jid=TEST_JID_4,
|
||||
ip_address=TEST_HOST_4,
|
||||
audio_transport="v2",
|
||||
),
|
||||
]
|
||||
client.get_beolink_listeners = AsyncMock()
|
||||
@@ -356,11 +359,13 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
friendly_name=TEST_FRIENDLY_NAME_3,
|
||||
jid=TEST_JID_3,
|
||||
ip_address=TEST_HOST_3,
|
||||
audio_transport="v2",
|
||||
),
|
||||
BeolinkPeer(
|
||||
friendly_name=TEST_FRIENDLY_NAME_4,
|
||||
jid=TEST_JID_4,
|
||||
ip_address=TEST_HOST_4,
|
||||
audio_transport="v2",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ TEST_PLAYBACK_METADATA_VIDEO = PlaybackContentMetadata(
|
||||
title="HDMI A",
|
||||
source_internal_id="hdmi_1",
|
||||
output_channel_processing="TrueImage",
|
||||
output_Channels="5.0.2",
|
||||
output_channels="5.0.2",
|
||||
)
|
||||
TEST_PLAYBACK_ERROR = PlaybackError(error="Test error")
|
||||
TEST_PLAYBACK_PROGRESS = PlaybackProgress(progress=123)
|
||||
|
||||
@@ -582,7 +582,7 @@ async def test_async_update_beolink_listener(
|
||||
playback_metadata_callback(
|
||||
PlaybackContentMetadata(
|
||||
remote_leader=BeolinkLeader(
|
||||
friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2
|
||||
friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2, audio_transport="v2"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -22,11 +22,13 @@ def airsensor_fixture() -> tuple[AsyncMock, str]:
|
||||
unique_id="BleBox-windRainSensor-ea68e74f4f49-0.rain",
|
||||
full_name="windRainSensor-0.rain",
|
||||
device_class="moisture",
|
||||
index=None,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My rain sensor")
|
||||
type(product).model = PropertyMock(return_value="rainSensor")
|
||||
return feature, "binary_sensor.my_rain_sensor_windrainsensor_0_rain"
|
||||
return feature, "binary_sensor.my_rain_sensor_moisture"
|
||||
|
||||
|
||||
@pytest.fixture(name="open_sensor")
|
||||
@@ -39,29 +41,103 @@ def open_sensor_fixture() -> tuple[AsyncMock, str]:
|
||||
full_name="openSensor-0.open",
|
||||
device_class="open",
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My open sensor")
|
||||
type(product).model = PropertyMock(return_value="openSensor")
|
||||
return feature, "binary_sensor.my_open_sensor_opensensor_0_open"
|
||||
return feature, "binary_sensor.my_open_sensor_window"
|
||||
|
||||
|
||||
@pytest.fixture(name="inputsensor")
|
||||
def inputsensor_fixture() -> tuple[AsyncMock, str]:
|
||||
"""Return a default inputSensor fixture."""
|
||||
feature: AsyncMock = mock_feature(
|
||||
"binary_sensors",
|
||||
blebox_uniapi.binary_sensor.Input,
|
||||
unique_id="BleBox-inputSensorD-aa11bb22cc33-0.input",
|
||||
full_name="inputSensorD-0.input",
|
||||
device_class="input",
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My input sensor")
|
||||
type(product).model = PropertyMock(return_value="inputSensorD")
|
||||
return feature, "binary_sensor.my_input_sensor"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"fixture_name",
|
||||
"unique_id",
|
||||
"expected_name",
|
||||
"expected_device_class",
|
||||
"expected_state",
|
||||
"expected_device_name",
|
||||
),
|
||||
[
|
||||
pytest.param(
|
||||
"rainsensor",
|
||||
"BleBox-windRainSensor-ea68e74f4f49-0.rain",
|
||||
"My rain sensor Moisture",
|
||||
BinarySensorDeviceClass.MOISTURE,
|
||||
STATE_ON,
|
||||
"My rain sensor",
|
||||
id="moisture",
|
||||
),
|
||||
pytest.param(
|
||||
"inputsensor",
|
||||
"BleBox-inputSensorD-aa11bb22cc33-0.input",
|
||||
"My input sensor",
|
||||
None,
|
||||
STATE_ON,
|
||||
"My input sensor",
|
||||
id="input",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_init(
|
||||
rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant
|
||||
hass: HomeAssistant,
|
||||
fixture_name: str,
|
||||
unique_id: str,
|
||||
expected_name: str,
|
||||
expected_device_class: BinarySensorDeviceClass | None,
|
||||
expected_state: str,
|
||||
expected_device_name: str,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
"""Test binary_sensor initialisation."""
|
||||
_, entity_id = rainsensor
|
||||
_, entity_id = request.getfixturevalue(fixture_name)
|
||||
entry = await async_setup_entity(hass, entity_id)
|
||||
assert entry.unique_id == "BleBox-windRainSensor-ea68e74f4f49-0.rain"
|
||||
assert entry.unique_id == unique_id
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My rain sensor windRainSensor-0.rain"
|
||||
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE
|
||||
assert state.state == STATE_ON
|
||||
assert state.name == expected_name
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == expected_device_class
|
||||
assert state.state == expected_state
|
||||
|
||||
device = device_registry.async_get(entry.device_id)
|
||||
assert device.name == expected_device_name
|
||||
|
||||
assert device.name == "My rain sensor"
|
||||
|
||||
async def test_binary_sensor_with_name(hass: HomeAssistant) -> None:
|
||||
"""Test that a binary sensor with a feature name uses it as the entity name."""
|
||||
feature = mock_feature(
|
||||
"binary_sensors",
|
||||
blebox_uniapi.binary_sensor.Rain,
|
||||
unique_id="BleBox-windRainSensor-ea68e74f4f49-0.rain",
|
||||
full_name="windRainSensor-0.rain",
|
||||
device_class="moisture",
|
||||
index=0,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value="Front yard")
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My rain sensor")
|
||||
type(product).model = PropertyMock(return_value="rainSensor")
|
||||
|
||||
await async_setup_entity(hass, "binary_sensor.my_rain_sensor_front_yard")
|
||||
state = hass.states.get("binary_sensor.my_rain_sensor_front_yard")
|
||||
assert state.name == "My rain sensor Front yard"
|
||||
|
||||
|
||||
async def test_open_sensor_init(
|
||||
@@ -75,7 +151,7 @@ async def test_open_sensor_init(
|
||||
assert entry.unique_id == "BleBox-openSensor-1afe34db9437-0.open"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My open sensor openSensor-0.open"
|
||||
assert state.name == "My open sensor Window"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW
|
||||
|
||||
device = device_registry.async_get(entry.device_id)
|
||||
|
||||
@@ -39,7 +39,7 @@ def tv_lift_box_fixture(caplog: pytest.LogCaptureFixture):
|
||||
type(product).model = PropertyMock(return_value="tvLiftBox")
|
||||
type(product)._query_string = PropertyMock(return_value="open_or_stop")
|
||||
|
||||
return (feature, "button.my_tvliftbox_tvliftbox_open_or_stop")
|
||||
return (feature, "button.my_tvliftbox")
|
||||
|
||||
|
||||
async def test_tvliftbox_init(
|
||||
@@ -54,7 +54,7 @@ async def test_tvliftbox_init(
|
||||
|
||||
assert entry.unique_id == "BleBox-tvLiftBox-4a3fdaad90aa-open_or_stop"
|
||||
|
||||
assert state.name == "My tvLiftBox tvLiftBox-open_or_stop"
|
||||
assert state.name == "My tvLiftBox"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -55,7 +55,7 @@ def saunabox_fixture():
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My sauna")
|
||||
type(product).model = PropertyMock(return_value="saunaBox")
|
||||
return (feature, "climate.my_sauna_saunabox_thermostat")
|
||||
return (feature, "climate.my_sauna")
|
||||
|
||||
|
||||
@pytest.fixture(name="thermobox")
|
||||
@@ -78,7 +78,7 @@ def thermobox_fixture():
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My thermo")
|
||||
type(product).model = PropertyMock(return_value="thermoBox")
|
||||
return (feature, "climate.my_thermo_thermobox_thermostat")
|
||||
return (feature, "climate.my_thermo")
|
||||
|
||||
|
||||
async def test_init(
|
||||
@@ -91,7 +91,7 @@ async def test_init(
|
||||
assert entry.unique_id == "BleBox-saunaBox-1afe34db9437-thermostat"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My sauna saunaBox-thermostat"
|
||||
assert state.name == "My sauna"
|
||||
|
||||
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
assert supported_features & ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
@@ -63,7 +63,7 @@ def shutterbox_fixture():
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My shutter")
|
||||
type(product).model = PropertyMock(return_value="shutterBox")
|
||||
return (feature, "cover.my_shutter_shutterbox_position")
|
||||
return (feature, "cover.my_shutter")
|
||||
|
||||
|
||||
@pytest.fixture(name="gatebox")
|
||||
@@ -86,7 +86,7 @@ def gatebox_fixture():
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My gatebox")
|
||||
type(product).model = PropertyMock(return_value="gateBox")
|
||||
return (feature, "cover.my_gatebox_gatebox_position")
|
||||
return (feature, "cover.my_gatebox")
|
||||
|
||||
|
||||
@pytest.fixture(name="gatecontroller")
|
||||
@@ -109,7 +109,7 @@ def gate_fixture():
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My gate controller")
|
||||
type(product).model = PropertyMock(return_value="gateController")
|
||||
return (feature, "cover.my_gate_controller_gatecontroller_position")
|
||||
return (feature, "cover.my_gate_controller")
|
||||
|
||||
|
||||
async def test_init_gatecontroller(
|
||||
@@ -122,7 +122,7 @@ async def test_init_gatecontroller(
|
||||
assert entry.unique_id == "BleBox-gateController-2bee34e750b8-position"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My gate controller gateController-position"
|
||||
assert state.name == "My gate controller"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GATE
|
||||
|
||||
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
@@ -153,7 +153,7 @@ async def test_init_shutterbox(
|
||||
assert entry.unique_id == "BleBox-shutterBox-2bee34e750b8-position"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My shutter shutterBox-position"
|
||||
assert state.name == "My shutter"
|
||||
assert entry.original_device_class == CoverDeviceClass.SHUTTER
|
||||
|
||||
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
@@ -184,7 +184,7 @@ async def test_init_gatebox(
|
||||
assert entry.unique_id == "BleBox-gateBox-1afe34db9437-position"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My gatebox gateBox-position"
|
||||
assert state.name == "My gatebox"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.DOOR
|
||||
|
||||
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
@@ -248,7 +248,7 @@ async def test_device_class_from_unified_cover_type(
|
||||
type(product).name = PropertyMock(return_value="My shutter")
|
||||
type(product).model = PropertyMock(return_value="shutterBox")
|
||||
|
||||
entity_id = "cover.my_shutter_shutterbox_position"
|
||||
entity_id = "cover.my_shutter"
|
||||
entry = await async_setup_entity(hass, entity_id)
|
||||
assert entry.original_device_class == expected_device_class
|
||||
|
||||
|
||||
@@ -50,11 +50,12 @@ def dimmer_fixture():
|
||||
supports_white=False,
|
||||
color_mode=blebox_uniapi.light.BleboxColorMode.MONO,
|
||||
effect_list=None,
|
||||
index=None,
|
||||
)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My dimmer")
|
||||
type(product).model = PropertyMock(return_value="dimmerBox")
|
||||
return (feature, "light.my_dimmer_dimmerbox_brightness")
|
||||
return (feature, "light.my_dimmer")
|
||||
|
||||
|
||||
async def test_dimmer_init(
|
||||
@@ -67,7 +68,7 @@ async def test_dimmer_init(
|
||||
assert entry.unique_id == "BleBox-dimmerBox-1afe34e750b8-brightness"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My dimmer dimmerBox-brightness"
|
||||
assert state.name == "My dimmer"
|
||||
|
||||
color_modes = state.attributes[ATTR_SUPPORTED_COLOR_MODES]
|
||||
assert color_modes == [ColorMode.BRIGHTNESS]
|
||||
@@ -209,11 +210,12 @@ def wlightboxs_fixture():
|
||||
supports_white=False,
|
||||
color_mode=blebox_uniapi.light.BleboxColorMode.MONO,
|
||||
effect_list=["NONE", "PL", "RELAX"],
|
||||
index=None,
|
||||
)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My wLightBoxS")
|
||||
type(product).model = PropertyMock(return_value="wLightBoxS")
|
||||
return (feature, "light.my_wlightboxs_wlightboxs_color")
|
||||
return (feature, "light.my_wlightboxs")
|
||||
|
||||
|
||||
async def test_wlightbox_s_init(
|
||||
@@ -226,7 +228,7 @@ async def test_wlightbox_s_init(
|
||||
assert entry.unique_id == "BleBox-wLightBoxS-1afe34e750b8-color"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My wLightBoxS wLightBoxS-color"
|
||||
assert state.name == "My wLightBoxS"
|
||||
|
||||
color_modes = state.attributes[ATTR_SUPPORTED_COLOR_MODES]
|
||||
assert color_modes == [ColorMode.BRIGHTNESS]
|
||||
@@ -305,11 +307,12 @@ def wlightbox_fixture():
|
||||
color_mode=blebox_uniapi.light.BleboxColorMode.RGBW,
|
||||
effect="NONE",
|
||||
effect_list=["NONE", "PL", "POLICE"],
|
||||
index=None,
|
||||
)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My wLightBox")
|
||||
type(product).model = PropertyMock(return_value="wLightBox")
|
||||
return (feature, "light.my_wlightbox_wlightbox_color")
|
||||
return (feature, "light.my_wlightbox")
|
||||
|
||||
|
||||
@pytest.fixture(name="wlightbox_ct")
|
||||
@@ -330,11 +333,12 @@ def wlightbox_ct_fixture() -> tuple[MagicMock, str]:
|
||||
color_mode=blebox_uniapi.light.BleboxColorMode.CT,
|
||||
effect="NONE",
|
||||
effect_list=["NONE", "PL", "POLICE"],
|
||||
index=None,
|
||||
)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My wLightBox")
|
||||
type(product).model = PropertyMock(return_value="wLightBox")
|
||||
return feature, "light.my_wlightbox_wlightbox_ct"
|
||||
return feature, "light.my_wlightbox"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kelvin_requested", [1000, 2700, 3000, 4000, 5000, 6500, 8000])
|
||||
@@ -389,7 +393,7 @@ async def test_wlightbox_init(
|
||||
assert entry.unique_id == "BleBox-wLightBox-1afe34e750b8-color"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My wLightBox wLightBox-color"
|
||||
assert state.name == "My wLightBox"
|
||||
|
||||
color_modes = state.attributes[ATTR_SUPPORTED_COLOR_MODES]
|
||||
assert color_modes == [ColorMode.RGBW]
|
||||
@@ -650,3 +654,69 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None:
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.attributes[ATTR_EFFECT] == "POLICE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("index", "color_mode", "entity_id", "expected_name"),
|
||||
[
|
||||
(
|
||||
0,
|
||||
blebox_uniapi.light.BleboxColorMode.MONO,
|
||||
"light.my_wlightbox_channel_1",
|
||||
"My wLightBox Channel 1",
|
||||
),
|
||||
(
|
||||
1,
|
||||
blebox_uniapi.light.BleboxColorMode.MONO,
|
||||
"light.my_wlightbox_channel_2",
|
||||
"My wLightBox Channel 2",
|
||||
),
|
||||
(
|
||||
3,
|
||||
blebox_uniapi.light.BleboxColorMode.MONO,
|
||||
"light.my_wlightbox_channel_4",
|
||||
"My wLightBox Channel 4",
|
||||
),
|
||||
(
|
||||
0,
|
||||
blebox_uniapi.light.BleboxColorMode.CTx2,
|
||||
"light.my_wlightbox_channel_1",
|
||||
"My wLightBox Channel 1",
|
||||
),
|
||||
(
|
||||
1,
|
||||
blebox_uniapi.light.BleboxColorMode.CTx2,
|
||||
"light.my_wlightbox_channel_2",
|
||||
"My wLightBox Channel 2",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_multichannel_light_name(
|
||||
hass: HomeAssistant,
|
||||
index: int,
|
||||
color_mode: blebox_uniapi.light.BleboxColorMode,
|
||||
entity_id: str,
|
||||
expected_name: str,
|
||||
) -> None:
|
||||
"""Test that multi-channel lights get the correct channel name."""
|
||||
feature = mock_feature(
|
||||
"lights",
|
||||
blebox_uniapi.light.Light,
|
||||
unique_id=f"BleBox-wLightBox-1afe34e750b8-brightness_{index}",
|
||||
full_name=f"wLightBox-brightness_{index}",
|
||||
device_class=None,
|
||||
brightness=None,
|
||||
is_on=None,
|
||||
supports_color=False,
|
||||
supports_white=False,
|
||||
color_mode=color_mode,
|
||||
effect_list=None,
|
||||
index=index,
|
||||
)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My wLightBox")
|
||||
type(product).model = PropertyMock(return_value="wLightBox")
|
||||
|
||||
await async_setup_entity(hass, entity_id)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == expected_name
|
||||
|
||||
@@ -19,7 +19,14 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .conftest import async_setup_config_entry, async_setup_entity, mock_feature
|
||||
from .conftest import (
|
||||
async_setup_config_entry,
|
||||
async_setup_entities,
|
||||
async_setup_entity,
|
||||
mock_feature,
|
||||
mock_only_feature,
|
||||
setup_product_mock,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -35,11 +42,13 @@ def airsensor_fixture():
|
||||
device_class="pm1",
|
||||
unit="concentration_of_mp",
|
||||
native_value=None,
|
||||
index=None,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My air sensor")
|
||||
type(product).model = PropertyMock(return_value="airSensor")
|
||||
return (feature, "sensor.my_air_sensor_airsensor_0_air")
|
||||
return (feature, "sensor.my_air_sensor_pm1")
|
||||
|
||||
|
||||
@pytest.fixture(name="tempsensor")
|
||||
@@ -54,11 +63,13 @@ def tempsensor_fixture():
|
||||
unit="celsius",
|
||||
current=None,
|
||||
native_value=None,
|
||||
index=None,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My temperature sensor")
|
||||
type(product).model = PropertyMock(return_value="tempSensor")
|
||||
return (feature, "sensor.my_temperature_sensor_tempsensor_0_temperature")
|
||||
return (feature, "sensor.my_temperature_sensor_temperature")
|
||||
|
||||
|
||||
async def test_init(
|
||||
@@ -71,7 +82,7 @@ async def test_init(
|
||||
assert entry.unique_id == "BleBox-tempSensor-1afe34db9437-0.temperature"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My temperature sensor tempSensor-0.temperature"
|
||||
assert state.name == "My temperature sensor Temperature"
|
||||
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE
|
||||
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS
|
||||
@@ -130,7 +141,7 @@ async def test_airsensor_init(
|
||||
assert entry.unique_id == "BleBox-airSensor-1afe34db9437-0.air"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My air sensor airSensor-0.air"
|
||||
assert state.name == "My air sensor PM1"
|
||||
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.PM1
|
||||
assert state.state == STATE_UNKNOWN
|
||||
@@ -144,6 +155,75 @@ async def test_airsensor_init(
|
||||
assert device.sw_version == "1.23"
|
||||
|
||||
|
||||
async def test_multi_sensor_single_has_no_channel_suffix(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that a single indexed sensor shows no channel suffix."""
|
||||
feature = mock_feature(
|
||||
"sensors",
|
||||
blebox_uniapi.sensor.GenericSensor,
|
||||
unique_id="BleBox-smartMeter-aabbcc-voltage_0",
|
||||
full_name="smartMeter-voltage_0",
|
||||
device_class="voltage",
|
||||
unit="volt",
|
||||
native_value=None,
|
||||
sensor_id=0,
|
||||
index=0,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My smart meter")
|
||||
type(product).model = PropertyMock(return_value="smartMeter")
|
||||
|
||||
await async_setup_entity(hass, "sensor.my_smart_meter_voltage")
|
||||
state = hass.states.get("sensor.my_smart_meter_voltage")
|
||||
assert state.name == "My smart meter Voltage"
|
||||
|
||||
|
||||
async def test_multi_sensor_multiple_have_channel_suffix(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test SmartMeter-like device: index=0 (summary) has no suffix, index=1-3 (phases) get phase number suffix."""
|
||||
features = [
|
||||
mock_only_feature(
|
||||
blebox_uniapi.sensor.GenericSensor,
|
||||
unique_id=f"BleBox-smartMeter-aabbcc-voltage_{i}",
|
||||
full_name=f"smartMeter-voltage_{i}",
|
||||
device_class="voltage",
|
||||
unit="volt",
|
||||
native_value=None,
|
||||
sensor_id=i,
|
||||
index=i,
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
|
||||
product = setup_product_mock("sensors", features)
|
||||
type(product).name = PropertyMock(return_value="My smart meter")
|
||||
type(product).model = PropertyMock(return_value="smartMeter")
|
||||
type(product).brand = PropertyMock(return_value="BleBox")
|
||||
type(product).firmware_version = PropertyMock(return_value="1.23")
|
||||
type(product).unique_id = PropertyMock(return_value="aabbcc112233")
|
||||
|
||||
for feature in features:
|
||||
type(feature).product = PropertyMock(return_value=product)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
feature.async_update = AsyncMock()
|
||||
|
||||
entity_ids = [
|
||||
"sensor.my_smart_meter_voltage",
|
||||
"sensor.my_smart_meter_voltage_1",
|
||||
"sensor.my_smart_meter_voltage_2",
|
||||
"sensor.my_smart_meter_voltage_3",
|
||||
]
|
||||
await async_setup_entities(hass, entity_ids)
|
||||
|
||||
assert hass.states.get(entity_ids[0]).name == "My smart meter Voltage"
|
||||
assert hass.states.get(entity_ids[1]).name == "My smart meter Voltage 1"
|
||||
assert hass.states.get(entity_ids[2]).name == "My smart meter Voltage 2"
|
||||
assert hass.states.get(entity_ids[3]).name == "My smart meter Voltage 3"
|
||||
|
||||
|
||||
async def test_airsensor_update(airsensor, hass: HomeAssistant) -> None:
|
||||
"""Test air quality sensor state after update."""
|
||||
|
||||
@@ -172,10 +252,11 @@ def open_status_sensor_fixture():
|
||||
device_class="openStatus",
|
||||
native_value=None,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My open sensor")
|
||||
type(product).model = PropertyMock(return_value="openSensor")
|
||||
return (feature, "sensor.my_open_sensor_opensensor_0_openstatus")
|
||||
return (feature, "sensor.my_open_sensor_open_status")
|
||||
|
||||
|
||||
async def test_open_status_sensor_init(open_status_sensor, hass: HomeAssistant) -> None:
|
||||
@@ -244,10 +325,11 @@ def co2_definition_sensor_fixture():
|
||||
device_class="co2Definition",
|
||||
native_value=None,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My CO2 sensor")
|
||||
type(product).model = PropertyMock(return_value="co2Sensor")
|
||||
return (feature, "sensor.my_co2_sensor_co2sensor_0_co2definition")
|
||||
return (feature, "sensor.my_co2_sensor_carbon_dioxide_level")
|
||||
|
||||
|
||||
async def test_co2_definition_sensor_init(
|
||||
|
||||
@@ -41,11 +41,14 @@ def switchbox_fixture():
|
||||
full_name="switchBox-0.relay",
|
||||
device_class="relay",
|
||||
is_on=False,
|
||||
index=0,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
feature.async_update = AsyncMock()
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My switch box")
|
||||
type(product).model = PropertyMock(return_value="switchBox")
|
||||
return (feature, "switch.my_switch_box_switchbox_0_relay")
|
||||
return (feature, "switch.my_switch_box")
|
||||
|
||||
|
||||
async def test_switchbox_init(
|
||||
@@ -59,7 +62,7 @@ async def test_switchbox_init(
|
||||
assert entry.unique_id == "BleBox-switchBox-1afe34e750b8-0.relay"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My switch box switchBox-0.relay"
|
||||
assert state.name == "My switch box"
|
||||
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH
|
||||
|
||||
@@ -148,13 +151,16 @@ async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None:
|
||||
def relay_mock(relay_id=0):
|
||||
"""Return a default switchBoxD switch entity mock."""
|
||||
|
||||
return mock_only_feature(
|
||||
feature = mock_only_feature(
|
||||
blebox_uniapi.switch.Switch,
|
||||
unique_id=f"BleBox-switchBoxD-1afe34e750b8-{relay_id}.relay",
|
||||
full_name=f"switchBoxD-{relay_id}.relay",
|
||||
device_class="relay",
|
||||
is_on=None,
|
||||
index=relay_id,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value=None)
|
||||
return feature
|
||||
|
||||
|
||||
@pytest.fixture(name="switchbox_d")
|
||||
@@ -178,7 +184,7 @@ def switchbox_d_fixture():
|
||||
|
||||
return (
|
||||
features,
|
||||
["switch.my_relays_switchboxd_0_relay", "switch.my_relays_switchboxd_1_relay"],
|
||||
["switch.my_relays", "switch.my_relays_2"],
|
||||
)
|
||||
|
||||
|
||||
@@ -195,7 +201,7 @@ async def test_switchbox_d_init(
|
||||
assert entry.unique_id == "BleBox-switchBoxD-1afe34e750b8-0.relay"
|
||||
|
||||
state = hass.states.get(entity_ids[0])
|
||||
assert state.name == "My relays switchBoxD-0.relay"
|
||||
assert state.name == "My relays"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
@@ -211,7 +217,7 @@ async def test_switchbox_d_init(
|
||||
assert entry.unique_id == "BleBox-switchBoxD-1afe34e750b8-1.relay"
|
||||
|
||||
state = hass.states.get(entity_ids[1])
|
||||
assert state.name == "My relays switchBoxD-1.relay"
|
||||
assert state.name == "My relays"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
@@ -347,6 +353,28 @@ async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None:
|
||||
assert hass.states.get(entity_ids[1]).state == STATE_OFF
|
||||
|
||||
|
||||
async def test_switchbox_with_name(hass: HomeAssistant) -> None:
|
||||
"""Test that a switch with a feature name uses it as the entity name."""
|
||||
feature = mock_feature(
|
||||
"switches",
|
||||
blebox_uniapi.switch.Switch,
|
||||
unique_id="BleBox-switchBoxD-1afe34e750b8-0.relay",
|
||||
full_name="switchBoxD-0.relay",
|
||||
device_class="relay",
|
||||
is_on=False,
|
||||
index=0,
|
||||
)
|
||||
type(feature).name = PropertyMock(return_value="Garden lights")
|
||||
feature.async_update = AsyncMock()
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My switch box")
|
||||
type(product).model = PropertyMock(return_value="switchBoxD")
|
||||
|
||||
await async_setup_entity(hass, "switch.my_switch_box_garden_lights")
|
||||
state = hass.states.get("switch.my_switch_box_garden_lights")
|
||||
assert state.name == "My switch box Garden lights"
|
||||
|
||||
|
||||
ALL_SWITCH_FIXTURES = ["switchbox", "switchbox_d"]
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ def firmwareupdate_fixture() -> tuple[blebox_uniapi.update.Update, str]:
|
||||
product = feature.product
|
||||
type(product).name = PropertyMock(return_value="My airSensor")
|
||||
type(product).model = PropertyMock(return_value="airSensor")
|
||||
return (feature, "update.my_airsensor_airsensor_firmware")
|
||||
return (feature, "update.my_airsensor_firmware")
|
||||
|
||||
|
||||
async def test_init(
|
||||
@@ -63,7 +63,7 @@ async def test_init(
|
||||
assert entry.unique_id == "BleBox-airSensor-4a3fdaad90aa-firmware"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.name == "My airSensor airSensor-firmware"
|
||||
assert state.name == "My airSensor Firmware"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE
|
||||
|
||||
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# serializer version: 1
|
||||
# name: test_diagnostics
|
||||
dict({
|
||||
'device': dict({
|
||||
'support_advanced_modes': False,
|
||||
'support_away_mode': False,
|
||||
'support_compressor_frequency': False,
|
||||
'support_energy_consumption': False,
|
||||
'support_fan_rate': False,
|
||||
'support_humidity': False,
|
||||
'support_outside_temperature': False,
|
||||
'support_swing_mode': False,
|
||||
'values': dict({
|
||||
'lztemp_c': '22',
|
||||
'lztemp_h': '22',
|
||||
'model': 'TESTMODEL',
|
||||
'name': 'Daikin Test',
|
||||
'ver': '1_0_0',
|
||||
'zone_name': 'Living',
|
||||
'zone_onoff': '1',
|
||||
}),
|
||||
}),
|
||||
'entry_data': dict({
|
||||
'host': '**REDACTED**',
|
||||
'mac': '**REDACTED**',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Tests for the diagnostics data provided by the Daikin integration."""
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.daikin.const import KEY_MAC
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .test_config_flow import HOST, MAC
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def test_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
zone_device,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
entry = MockConfigEntry(
|
||||
domain="daikin",
|
||||
unique_id=MAC,
|
||||
data={CONF_HOST: HOST, KEY_MAC: MAC},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot
|
||||
@@ -5,9 +5,12 @@ from unittest.mock import AsyncMock
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
|
||||
from homeassistant.components.duco.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, get_system_health_info
|
||||
|
||||
|
||||
@@ -53,3 +56,28 @@ async def test_system_health_no_loaded_entries(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await get_system_health_info(hass, DOMAIN) == {}
|
||||
|
||||
|
||||
async def test_system_health_multiple_loaded_entries(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test system health aggregates quotas for multiple loaded Duco boxes."""
|
||||
second_entry = MockConfigEntry(
|
||||
title="SECOND_BOX",
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "192.168.1.101"},
|
||||
unique_id="aa:bb:cc:dd:ee:00",
|
||||
)
|
||||
await setup_integration(hass, second_entry)
|
||||
mock_duco_client.async_get_write_requests_remaining.side_effect = [100, 75]
|
||||
|
||||
assert await async_setup_component(hass, "system_health", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
info = await get_system_health_info(hass, DOMAIN)
|
||||
|
||||
assert await info["write_requests_remaining"] == (
|
||||
"SILENT_CONNECT (aa:bb:cc:dd:ee:ff): 100; SECOND_BOX (aa:bb:cc:dd:ee:00): 75"
|
||||
)
|
||||
|
||||
@@ -74,6 +74,7 @@ async def test_humanify_homekit_changed_event(hass: HomeAssistant, hk_driver) ->
|
||||
async def test_bridge_with_triggers(
|
||||
hass: HomeAssistant,
|
||||
hk_driver,
|
||||
demo_cleanup,
|
||||
entity_registry: er.EntityRegistry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from homeassistant.components import hue
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import STATE_UNKNOWN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -185,3 +185,22 @@ async def test_grouped_light_level_sensor(
|
||||
assert (
|
||||
sensor.state == "999"
|
||||
) # Light level 30000 translates to 10^((30000-1)/10000) ≈ 999 lux
|
||||
|
||||
|
||||
async def test_light_level_sensor_none_value(
|
||||
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
|
||||
) -> None:
|
||||
"""Test that light level sensor handles None value without crashing."""
|
||||
# Modify the light_level sensor to have None value (simulates sensor unavailability)
|
||||
for resource in v2_resources_test_data:
|
||||
if resource.get("id") == "d504e7a4-9a18-4854-90fd-c5b6ac102c40":
|
||||
resource["light"]["light_level"] = None
|
||||
resource["light"]["light_level_valid"] = False
|
||||
break
|
||||
|
||||
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
|
||||
await setup_platform(hass, mock_bridge_v2, Platform.SENSOR)
|
||||
|
||||
sensor = hass.states.get("sensor.hue_motion_sensor_illuminance")
|
||||
assert sensor is not None
|
||||
assert sensor.state == STATE_UNKNOWN
|
||||
|
||||
@@ -110,4 +110,5 @@ def mock_hyponcloud(
|
||||
mock_client.get_monitor.side_effect = lambda plant_id, *args, **kwargs: (
|
||||
load_monitor_fixture[plant_id]
|
||||
)
|
||||
mock_client.hyponcloud_class = mock_hyponcloud
|
||||
yield mock_client
|
||||
|
||||
@@ -1,22 +1,52 @@
|
||||
"""Test the Hypontech Cloud config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from hyponcloud import AuthenticationError
|
||||
from hyponcloud import KNOWN_OEMS, AuthenticationError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hypontech.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.components.hypontech.config_flow import OEM_OPTIONS
|
||||
from homeassistant.components.hypontech.const import CONF_OEM, DEFAULT_OEM, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
NEXEN_OEM = 4
|
||||
TEST_ACCOUNT_ID = "2123456789123456789"
|
||||
TEST_USER_INPUT = {
|
||||
CONF_USERNAME: "test@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_OEM: str(DEFAULT_OEM),
|
||||
}
|
||||
TEST_ENTRY_DATA = {
|
||||
CONF_USERNAME: "test@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_OEM: DEFAULT_OEM,
|
||||
}
|
||||
TEST_REAUTH_INPUT = {
|
||||
CONF_USERNAME: "test@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
}
|
||||
|
||||
|
||||
def assert_oem_not_in_schema(result: ConfigFlowResult) -> None:
|
||||
"""Assert the form data schema does not contain the OEM field."""
|
||||
assert CONF_OEM not in {field.schema for field in result["data_schema"].schema}
|
||||
|
||||
|
||||
def test_oem_options_include_portal_url() -> None:
|
||||
"""Test OEM options include their portal URLs."""
|
||||
assert [
|
||||
{
|
||||
"value": str(oem.id),
|
||||
"label": f"{oem.name} ({oem.monitoring_url})",
|
||||
}
|
||||
for oem in KNOWN_OEMS
|
||||
] == OEM_OPTIONS
|
||||
|
||||
|
||||
async def test_user_flow(
|
||||
@@ -35,11 +65,32 @@ async def test_user_flow(
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test@example.com"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
assert result["result"].unique_id == "2123456789123456789"
|
||||
assert result["data"] == TEST_ENTRY_DATA
|
||||
assert result["result"].unique_id == TEST_ACCOUNT_ID
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_user_flow_with_oem(
|
||||
hass: HomeAssistant, mock_hyponcloud: AsyncMock, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test a successful user flow with a non-default OEM."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {**TEST_USER_INPUT, CONF_OEM: str(NEXEN_OEM)}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test@example.com"
|
||||
assert result["data"] == {**TEST_ENTRY_DATA, CONF_OEM: NEXEN_OEM}
|
||||
assert result["result"].unique_id == f"{NEXEN_OEM}:{TEST_ACCOUNT_ID}"
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
hyponcloud_class = cast(Mock, mock_hyponcloud.hyponcloud_class)
|
||||
assert hyponcloud_class.call_args.kwargs["oem"] == NEXEN_OEM
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error_message"),
|
||||
[
|
||||
@@ -104,16 +155,47 @@ async def test_reauth_flow(
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert_oem_not_in_schema(result)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{**TEST_USER_INPUT, CONF_PASSWORD: "password"},
|
||||
{**TEST_REAUTH_INPUT, CONF_PASSWORD: "password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_PASSWORD] == "password"
|
||||
assert CONF_OEM not in mock_config_entry.data
|
||||
|
||||
|
||||
async def test_reauth_flow_uses_stored_oem(
|
||||
hass: HomeAssistant, mock_hyponcloud: AsyncMock
|
||||
) -> None:
|
||||
"""Test reauthentication uses the stored OEM without exposing it."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**TEST_ENTRY_DATA, CONF_OEM: NEXEN_OEM},
|
||||
unique_id=f"{NEXEN_OEM}:{TEST_ACCOUNT_ID}",
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert_oem_not_in_schema(result)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{**TEST_REAUTH_INPUT, CONF_PASSWORD: "new-password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_PASSWORD] == "new-password"
|
||||
assert mock_config_entry.data[CONF_OEM] == NEXEN_OEM
|
||||
hyponcloud_class = cast(Mock, mock_hyponcloud.hyponcloud_class)
|
||||
assert hyponcloud_class.call_args.kwargs["oem"] == NEXEN_OEM
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -136,12 +218,13 @@ async def test_reauth_flow_errors(
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert_oem_not_in_schema(result)
|
||||
|
||||
mock_hyponcloud.connect.side_effect = side_effect
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{**TEST_USER_INPUT, CONF_PASSWORD: "new-password"},
|
||||
{**TEST_REAUTH_INPUT, CONF_PASSWORD: "new-password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -150,7 +233,7 @@ async def test_reauth_flow_errors(
|
||||
mock_hyponcloud.connect.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{**TEST_USER_INPUT, CONF_PASSWORD: "new-password"},
|
||||
{**TEST_REAUTH_INPUT, CONF_PASSWORD: "new-password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -164,13 +247,14 @@ async def test_reauth_flow_wrong_account(
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert_oem_not_in_schema(result)
|
||||
|
||||
mock_hyponcloud.get_admin_info.return_value.id = "different_account_id_456"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{**TEST_USER_INPUT, CONF_USERNAME: "different@example.com"},
|
||||
{**TEST_REAUTH_INPUT, CONF_USERNAME: "different@example.com"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Test the Hypontech Cloud init."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from hyponcloud import AuthenticationError, RequestError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hypontech.const import CONF_OEM, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
@@ -35,6 +38,28 @@ async def test_setup_entry(
|
||||
assert mock_config_entry.state is expected_state
|
||||
|
||||
|
||||
async def test_setup_entry_uses_oem(
|
||||
hass: HomeAssistant,
|
||||
mock_hyponcloud: AsyncMock,
|
||||
) -> None:
|
||||
"""Test setup entry passes the stored OEM to the API."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_USERNAME: "test@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_OEM: 4,
|
||||
},
|
||||
unique_id="4:2123456789123456789",
|
||||
)
|
||||
|
||||
await setup_integration(hass, config_entry)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
hyponcloud_class = cast(Mock, mock_hyponcloud.hyponcloud_class)
|
||||
assert hyponcloud_class.call_args.kwargs["oem"] == 4
|
||||
|
||||
|
||||
async def test_setup_and_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
|
||||
@@ -4,9 +4,10 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.components.hypontech.const import CONF_OEM, DOMAIN
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
@@ -25,3 +26,36 @@ async def test_sensors(
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_device_manufacturer_uses_oem(
|
||||
hass: HomeAssistant,
|
||||
mock_hyponcloud: AsyncMock,
|
||||
) -> None:
|
||||
"""Test device manufacturer uses the selected OEM."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_USERNAME: "test@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_OEM: 4,
|
||||
},
|
||||
unique_id="4:2123456789123456789",
|
||||
)
|
||||
|
||||
with patch("homeassistant.components.hypontech._PLATFORMS", [Platform.SENSOR]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
overview_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, mock_config_entry.unique_id)}
|
||||
)
|
||||
assert overview_device
|
||||
assert overview_device.manufacturer == "Nexen"
|
||||
|
||||
plant = mock_hyponcloud.get_list.return_value[0]
|
||||
plant_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, plant.plant_id)}
|
||||
)
|
||||
assert plant_device
|
||||
assert plant_device.manufacturer == "Nexen"
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot
|
||||
from pylitterbot import (
|
||||
Account,
|
||||
FeederRobot,
|
||||
LitterRobot3,
|
||||
LitterRobot4,
|
||||
LitterRobot5,
|
||||
Pet,
|
||||
Robot,
|
||||
)
|
||||
from pylitterbot.exceptions import InvalidCommandException
|
||||
from pylitterbot.robot.litterrobot4 import HopperStatus
|
||||
import pytest
|
||||
@@ -16,6 +24,8 @@ from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
|
||||
ROBOT_DATA = load_json_object_fixture("litter_robot_3_data.json", DOMAIN)
|
||||
ROBOT_4_DATA = load_json_object_fixture("litter_robot_4_data.json", DOMAIN)
|
||||
ROBOT_5_DATA = load_json_object_fixture("litter_robot_5_data.json", DOMAIN)
|
||||
ROBOT_5_PRO_DATA = load_json_object_fixture("litter_robot_5_pro_data.json", DOMAIN)
|
||||
FEEDER_ROBOT_DATA = load_json_object_fixture("feeder_robot_data.json", DOMAIN)
|
||||
PET_DATA = load_json_object_fixture("pet_data.json", DOMAIN)
|
||||
|
||||
@@ -23,7 +33,10 @@ PET_DATA = load_json_object_fixture("pet_data.json", DOMAIN)
|
||||
def create_mock_robot(
|
||||
robot_data: dict | None,
|
||||
account: Account,
|
||||
*,
|
||||
v4: bool,
|
||||
v5: bool,
|
||||
v5_pro: bool,
|
||||
feeder: bool,
|
||||
side_effect: Any | None = None,
|
||||
) -> Robot:
|
||||
@@ -31,7 +44,15 @@ def create_mock_robot(
|
||||
if not robot_data:
|
||||
robot_data = {}
|
||||
|
||||
if v4:
|
||||
if v5 or v5_pro:
|
||||
data = ROBOT_5_PRO_DATA if v5_pro else ROBOT_5_DATA
|
||||
robot = LitterRobot5(data={**data, **robot_data}, account=account)
|
||||
robot.reset = AsyncMock(side_effect=side_effect)
|
||||
robot.change_filter = AsyncMock(side_effect=side_effect)
|
||||
robot.set_night_light_brightness = AsyncMock(side_effect=side_effect)
|
||||
robot.set_night_light_mode = AsyncMock(side_effect=side_effect)
|
||||
robot.set_panel_brightness = AsyncMock(side_effect=side_effect)
|
||||
elif v4:
|
||||
robot = LitterRobot4(data={**ROBOT_4_DATA, **robot_data}, account=account)
|
||||
elif feeder:
|
||||
robot = FeederRobot(data={**FEEDER_ROBOT_DATA, **robot_data}, account=account)
|
||||
@@ -68,6 +89,8 @@ def create_mock_account(
|
||||
side_effect: Any | None = None,
|
||||
skip_robots: bool = False,
|
||||
v4: bool = False,
|
||||
v5: bool = False,
|
||||
v5_pro: bool = False,
|
||||
feeder: bool = False,
|
||||
pet: bool = False,
|
||||
) -> MagicMock:
|
||||
@@ -79,7 +102,17 @@ def create_mock_account(
|
||||
account.robots = (
|
||||
[]
|
||||
if skip_robots
|
||||
else [create_mock_robot(robot_data, account, v4, feeder, side_effect)]
|
||||
else [
|
||||
create_mock_robot(
|
||||
robot_data,
|
||||
account,
|
||||
v4=v4,
|
||||
v5=v5,
|
||||
v5_pro=v5_pro,
|
||||
feeder=feeder,
|
||||
side_effect=side_effect,
|
||||
)
|
||||
]
|
||||
)
|
||||
account.get_robots = lambda robot_class: [
|
||||
robot for robot in account.robots if isinstance(robot, robot_class)
|
||||
@@ -100,6 +133,18 @@ def mock_account_with_litterrobot_4() -> MagicMock:
|
||||
return create_mock_account(v4=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account_with_litterrobot_5() -> MagicMock:
|
||||
"""Mock account with Litter-Robot 5."""
|
||||
return create_mock_account(v5=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account_with_litterrobot_5_pro() -> MagicMock:
|
||||
"""Mock account with Litter-Robot 5 Pro."""
|
||||
return create_mock_account(v5_pro=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account_with_litterhopper() -> MagicMock:
|
||||
"""Mock account with LitterHopper attached to Litter-Robot 4."""
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"name": "Test",
|
||||
"serial": "LR5C010001",
|
||||
"type": "LR5",
|
||||
"timezone": "America/New_York",
|
||||
"powerStatus": "AC",
|
||||
"setupDateTime": "2022-08-28T17:01:12.644Z",
|
||||
"nextFilterReplacementDate": "2023-02-28T17:01:12.644Z",
|
||||
"state": {
|
||||
"odometerCleanCycles": 158,
|
||||
"odometerEmptyCycles": 1,
|
||||
"odometerFilterCycles": 0,
|
||||
"odometerPowerCycles": 8,
|
||||
"lastResetOdometerCleanCycles": 42,
|
||||
"DFINumberOfCycles": 104,
|
||||
"dfiFullCounter": 3,
|
||||
"catDetect": "CAT_DETECT_CLEAR",
|
||||
"isBonnetRemoved": false,
|
||||
"isDrawerRemoved": false,
|
||||
"isDrawerFull": false,
|
||||
"isLaserDirty": false,
|
||||
"isOnline": true,
|
||||
"isHopperInstalled": true,
|
||||
"isSleeping": false,
|
||||
"isNightLightOn": false,
|
||||
"isFirmwareUpdating": false,
|
||||
"isGasSensorFaultDetected": false,
|
||||
"isUsbFaultDetected": false,
|
||||
"weightSensor": 1200.0,
|
||||
"globeMotorFaultStatus": "FAULT_CLEAR",
|
||||
"globeMotorRetractFaultStatus": "FAULT_CLEAR",
|
||||
"pinchStatus": "CLEAR",
|
||||
"lastSeen": "2022-09-17T12:06:37.884Z",
|
||||
"setupDateTime": "2022-08-28T17:01:12.644Z",
|
||||
"firmwareVersions": {
|
||||
"mcuVersion": "10512.2560.2.53",
|
||||
"wifiVersion": "1.1.50"
|
||||
},
|
||||
"firmwareUpdateStatus": "NONE",
|
||||
"litterLevelPercent": 70.0,
|
||||
"globeLitterLevel": 460,
|
||||
"globeLitterLevelIndicator": "OPTIMAL",
|
||||
"robotState": "StRobotIdle",
|
||||
"displayCode": "DcModeIdle",
|
||||
"powerStatus": "AC",
|
||||
"wifiRssi": -53.0,
|
||||
"scoopsSaved": 3769
|
||||
},
|
||||
"litterRobotSettings": {
|
||||
"cycleDelay": 15,
|
||||
"isSmartWeightEnabled": true
|
||||
},
|
||||
"nightLightSettings": {
|
||||
"brightness": 50,
|
||||
"color": "#FFFFFF",
|
||||
"mode": "Auto"
|
||||
},
|
||||
"panelSettings": {
|
||||
"displayIntensity": "Medium",
|
||||
"isKeypadLocked": false
|
||||
},
|
||||
"soundSettings": {
|
||||
"volume": 75
|
||||
},
|
||||
"sleepSchedules": [
|
||||
{
|
||||
"dayOfWeek": 0,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 1,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 2,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 3,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 4,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 5,
|
||||
"isEnabled": false,
|
||||
"sleepTime": 1380,
|
||||
"wakeTime": 480
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 6,
|
||||
"isEnabled": false,
|
||||
"sleepTime": 1380,
|
||||
"wakeTime": 480
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "Test Pro",
|
||||
"serial": "LR5P010001",
|
||||
"type": "LR5_PRO",
|
||||
"timezone": "America/New_York",
|
||||
"powerStatus": "AC",
|
||||
"setupDateTime": "2022-08-28T17:01:12.644Z",
|
||||
"nextFilterReplacementDate": "2023-02-28T17:01:12.644Z",
|
||||
"state": {
|
||||
"odometerCleanCycles": 158,
|
||||
"odometerEmptyCycles": 1,
|
||||
"odometerFilterCycles": 0,
|
||||
"odometerPowerCycles": 8,
|
||||
"lastResetOdometerCleanCycles": 42,
|
||||
"DFINumberOfCycles": 104,
|
||||
"dfiFullCounter": 3,
|
||||
"catDetect": "CAT_DETECT_CLEAR",
|
||||
"isBonnetRemoved": false,
|
||||
"isDrawerRemoved": false,
|
||||
"isDrawerFull": false,
|
||||
"isLaserDirty": false,
|
||||
"isOnline": true,
|
||||
"isHopperInstalled": true,
|
||||
"isSleeping": false,
|
||||
"isNightLightOn": false,
|
||||
"isFirmwareUpdating": false,
|
||||
"isGasSensorFaultDetected": false,
|
||||
"isUsbFaultDetected": false,
|
||||
"weightSensor": 1200.0,
|
||||
"globeMotorFaultStatus": "FAULT_CLEAR",
|
||||
"globeMotorRetractFaultStatus": "FAULT_CLEAR",
|
||||
"pinchStatus": "CLEAR",
|
||||
"lastSeen": "2022-09-17T12:06:37.884Z",
|
||||
"setupDateTime": "2022-08-28T17:01:12.644Z",
|
||||
"serial": "LR5P010001",
|
||||
"type": "LR5_PRO",
|
||||
"firmwareVersions": {
|
||||
"mcuVersion": "10512.2560.2.53",
|
||||
"wifiVersion": "1.1.50"
|
||||
},
|
||||
"firmwareUpdateStatus": "NONE",
|
||||
"litterLevelPercent": 70.0,
|
||||
"globeLitterLevel": 460,
|
||||
"globeLitterLevelIndicator": "OPTIMAL",
|
||||
"robotState": "StRobotIdle",
|
||||
"displayCode": "DcModeIdle",
|
||||
"powerStatus": "AC",
|
||||
"wifiRssi": -53.0,
|
||||
"scoopsSaved": 3769
|
||||
},
|
||||
"litterRobotSettings": {
|
||||
"cycleDelay": 15,
|
||||
"isSmartWeightEnabled": true
|
||||
},
|
||||
"nightLightSettings": {
|
||||
"brightness": 50,
|
||||
"color": "#FFFFFF",
|
||||
"mode": "Auto"
|
||||
},
|
||||
"panelSettings": {
|
||||
"displayIntensity": "Medium",
|
||||
"isKeypadLocked": false
|
||||
},
|
||||
"soundSettings": {
|
||||
"volume": 50,
|
||||
"cameraAudioEnabled": false
|
||||
},
|
||||
"sleepSchedules": [
|
||||
{
|
||||
"dayOfWeek": 0,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 1,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 2,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 3,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 4,
|
||||
"isEnabled": true,
|
||||
"sleepTime": 1320,
|
||||
"wakeTime": 420
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 5,
|
||||
"isEnabled": false,
|
||||
"sleepTime": 1380,
|
||||
"wakeTime": 480
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 6,
|
||||
"isEnabled": false,
|
||||
"sleepTime": 1380,
|
||||
"wakeTime": 480
|
||||
}
|
||||
],
|
||||
"cameraMetadata": {
|
||||
"deviceId": "68f5f44bba1544a7cc8697c2",
|
||||
"serialNumber": "E0510076020EBFV",
|
||||
"spaceId": "0123456789abcdef01234567"
|
||||
}
|
||||
}
|
||||
@@ -45,3 +45,50 @@ async def test_litterhopper_binary_sensors(
|
||||
assert (
|
||||
state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_litter_robot_5_binary_sensors(
|
||||
hass: HomeAssistant,
|
||||
mock_account_with_litterrobot_5: MagicMock,
|
||||
) -> None:
|
||||
"""Tests Litter-Robot 5 binary sensors."""
|
||||
await setup_integration(hass, mock_account_with_litterrobot_5, BINARY_SENSOR_DOMAIN)
|
||||
|
||||
state = hass.states.get("binary_sensor.test_drawer_removed")
|
||||
assert state
|
||||
assert state.state == "off"
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
state = hass.states.get("binary_sensor.test_bonnet_removed")
|
||||
assert state
|
||||
assert state.state == "off"
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
state = hass.states.get("binary_sensor.test_laser_dirty")
|
||||
assert state
|
||||
assert state.state == "off"
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
state = hass.states.get("binary_sensor.test_hopper_connected")
|
||||
assert state
|
||||
assert state.state == "on"
|
||||
assert (
|
||||
state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_litter_robot_5_online_sensor(
|
||||
hass: HomeAssistant,
|
||||
mock_account_with_litterrobot_5: MagicMock,
|
||||
) -> None:
|
||||
"""Tests Litter-Robot 5 online binary sensor (diagnostic, disabled by default)."""
|
||||
await setup_integration(hass, mock_account_with_litterrobot_5, BINARY_SENSOR_DOMAIN)
|
||||
|
||||
state = hass.states.get("binary_sensor.test_online")
|
||||
assert state
|
||||
assert state.state == "on"
|
||||
assert (
|
||||
state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY
|
||||
)
|
||||
|
||||
@@ -59,3 +59,42 @@ async def test_button_command_exception(
|
||||
{ATTR_ENTITY_ID: BUTTON_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "robot_command"),
|
||||
[
|
||||
("button.test_reset", "reset"),
|
||||
("button.test_change_filter", "change_filter"),
|
||||
],
|
||||
)
|
||||
async def test_litter_robot_5_button(
|
||||
hass: HomeAssistant,
|
||||
mock_account_with_litterrobot_5: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
entity_id: str,
|
||||
robot_command: str,
|
||||
) -> None:
|
||||
"""Test the Litter-Robot 5 button entities."""
|
||||
await setup_integration(hass, mock_account_with_litterrobot_5, BUTTON_DOMAIN)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
entry = entity_registry.async_get(entity_id)
|
||||
assert entry
|
||||
assert entry.entity_category is EntityCategory.CONFIG
|
||||
|
||||
with freeze_time("2021-11-15 17:37:00", tz_offset=-7):
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
getattr(mock_account_with_litterrobot_5.robots[0], robot_command).call_count
|
||||
== 1
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from pylitterbot import LitterRobot3, LitterRobot4
|
||||
from pylitterbot import LitterRobot3, LitterRobot4, LitterRobot5
|
||||
from pylitterbot.robot.litterrobot4 import NightLightMode
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.select import (
|
||||
@@ -127,3 +128,77 @@ async def test_select_command_exception(
|
||||
{ATTR_ENTITY_ID: SELECT_ENTITY_ID, ATTR_OPTION: "7"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_litterrobot_5_globe_light(
|
||||
hass: HomeAssistant,
|
||||
mock_account_with_litterrobot_5: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Tests the Litter-Robot 5 globe light (night light mode) select entity."""
|
||||
entity_id = "select.test_globe_light"
|
||||
await setup_integration(hass, mock_account_with_litterrobot_5, SELECT_DOMAIN)
|
||||
|
||||
select = hass.states.get(entity_id)
|
||||
assert select
|
||||
assert len(select.attributes[ATTR_OPTIONS]) == 3
|
||||
assert select.state == "auto"
|
||||
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
assert entity_entry
|
||||
assert entity_entry.entity_category is EntityCategory.CONFIG
|
||||
|
||||
data = {ATTR_ENTITY_ID: entity_id}
|
||||
|
||||
robot: LitterRobot5 = mock_account_with_litterrobot_5.robots[0]
|
||||
|
||||
for option in select.attributes[ATTR_OPTIONS]:
|
||||
data[ATTR_OPTION] = option
|
||||
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
data,
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert robot.set_night_light_mode.call_count == 3
|
||||
robot.set_night_light_mode.assert_any_call(NightLightMode.OFF)
|
||||
robot.set_night_light_mode.assert_any_call(NightLightMode.ON)
|
||||
robot.set_night_light_mode.assert_any_call(NightLightMode.AUTO)
|
||||
|
||||
|
||||
async def test_litterrobot_5_panel_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_account_with_litterrobot_5: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Tests the Litter-Robot 5 panel brightness select entity."""
|
||||
entity_id = "select.test_panel_brightness"
|
||||
await setup_integration(hass, mock_account_with_litterrobot_5, SELECT_DOMAIN)
|
||||
|
||||
select = hass.states.get(entity_id)
|
||||
assert select
|
||||
assert len(select.attributes[ATTR_OPTIONS]) == 3
|
||||
assert select.state == "medium"
|
||||
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
assert entity_entry
|
||||
assert entity_entry.entity_category is EntityCategory.CONFIG
|
||||
|
||||
data = {ATTR_ENTITY_ID: entity_id}
|
||||
|
||||
robot: LitterRobot5 = mock_account_with_litterrobot_5.robots[0]
|
||||
robot.set_panel_brightness = AsyncMock(return_value=True)
|
||||
|
||||
for count, option in enumerate(select.attributes[ATTR_OPTIONS]):
|
||||
data[ATTR_OPTION] = option
|
||||
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
data,
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert robot.set_panel_brightness.call_count == count + 1
|
||||
|
||||
@@ -154,3 +154,36 @@ async def test_litterhopper_sensor(
|
||||
await setup_integration(hass, mock_account_with_litterhopper, SENSOR_DOMAIN)
|
||||
sensor = hass.states.get("sensor.test_hopper_status")
|
||||
assert sensor.state == "enabled"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_litter_robot_5_sensor(
|
||||
hass: HomeAssistant, mock_account_with_litterrobot_5: MagicMock
|
||||
) -> None:
|
||||
"""Tests Litter-Robot 5 sensors."""
|
||||
await setup_integration(hass, mock_account_with_litterrobot_5, SENSOR_DOMAIN)
|
||||
|
||||
sensor = hass.states.get("sensor.test_litter_level")
|
||||
assert sensor
|
||||
assert sensor.state == "70.0"
|
||||
assert sensor.attributes["unit_of_measurement"] == PERCENTAGE
|
||||
|
||||
sensor = hass.states.get("sensor.test_pet_weight")
|
||||
assert sensor
|
||||
assert sensor.state == "12.0"
|
||||
assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS
|
||||
|
||||
sensor = hass.states.get("sensor.test_scoops_saved")
|
||||
assert sensor
|
||||
assert sensor.state == "3769"
|
||||
assert sensor.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
sensor = hass.states.get("sensor.test_next_filter_replacement")
|
||||
assert sensor
|
||||
assert sensor.state == "2023-02-28T17:01:12+00:00"
|
||||
assert sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP
|
||||
|
||||
sensor = hass.states.get("sensor.test_total_cycles")
|
||||
assert sensor
|
||||
assert sensor.state == "158"
|
||||
assert sensor.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user