Compare commits

...

36 Commits

Author SHA1 Message Date
Paul Bottein bb4f4e46c9 Add binary sensors to Yoto 2026-06-12 15:58:31 +02:00
Paul Bottein 7c9e5ad31b Bump yoto-api to 4.1.0 (#173601) 2026-06-12 14:24:03 +02:00
Erwin Douna 540e9a3d3b MELCloud Home add parallel updates (#173587) 2026-06-12 14:11:27 +02:00
Colin 11d4b39a9e openevse: Mark as silver (#173550) 2026-06-12 12:28:21 +02:00
Åke Strandberg 8379747a5b Add aqvify tests to reach 100% coverage (#173467) 2026-06-12 12:03:13 +02:00
Hai-Nam Nguyen 70a54d333c Add OEM support to Hypontech (#173472)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-12 11:59:07 +02:00
Josef Zweck 92b888b11d Translate exceptions in opendisplay (#173582) 2026-06-12 11:49:02 +02:00
Franck Nijhof 4b50008c73 Fix iCloud RuntimeError on unload by running cancel in executor (#173537) 2026-06-12 10:27:40 +02:00
Franck Nijhof e4b5fcf539 Convert RainMachine hw_version to string for device registry (#173545) 2026-06-12 09:00:38 +02:00
Franck Nijhof a2bdf4627f Convert OpenGarage sw_version to string for device registry (#173546) 2026-06-12 08:57:52 +02:00
Franck Nijhof 42c05f0998 Fix Rituals Perfume Genie sw_version dict passed to device registry (#173552) 2026-06-12 08:30:57 +02:00
Stefan Agner ee30356217 Refresh preferred Thread border agent address on OTBR reconnect (#173508)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
2026-06-12 07:57:06 +02:00
renovate[bot] d8860fc001 Update ruff (#173575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 07:36:07 +02:00
Erwin Douna 0bf27ad6be MELCloud Home add sensor platform (#173529) 2026-06-12 07:29:02 +02:00
Franck Nijhof 2e8e5c63e8 Fix Hue light level sensor crash on None value (#173532) 2026-06-11 23:17:04 +02:00
Legendberg 99096e4b65 Add Litter-Robot 5 basic entity support (#165879)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-11 22:47:45 +02:00
Franck Nijhof 594bcff43f Fix Hue grouped light icon by adding translation_key (#173536) 2026-06-11 22:45:43 +02:00
bkobus-bbx e99cb27015 Add has_entity_name and translation keys to blebox entities (#170089)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-11 22:43:08 +02:00
James Myatt 2aa1da7216 (bring) Fix docstring (#173451) 2026-06-11 22:32:15 +02:00
Robert Resch edbb27a912 Bump gh aw to 0.79.6 (#173553) 2026-06-11 22:28:29 +02:00
Ronald van der Meer 4cf5509bc1 Fix Duco system health for multiple loaded entries (#173324) 2026-06-11 22:28:08 +02:00
Tom Cassady aa1940095e Fix UniFi Protect ufp_set debug log printing UndefinedType for translation-key entities (#173460) 2026-06-11 22:01:28 +02:00
Mark Purcell 437d33d791 Add diagnostics platform to Daikin integration (#173481) 2026-06-11 21:46:10 +02:00
Erwin Douna 770488f0d4 MELCloud Home fixing typo (#173530) 2026-06-11 21:13:57 +02:00
Erik Montnemery cefbb109d2 Add missing file cleanup to homekit tests (#173513) 2026-06-11 21:03:28 +02:00
Ernst Klamer d9aa99e338 Bump bthome-ble to 3.23.4 (#173526) 2026-06-11 20:55:37 +03:00
Erwin Douna df49891f40 MELCloud Home add binary sensor (#173497) 2026-06-11 18:06:24 +02:00
Bram Kragten 9c86fe2ac5 Update frontend to 20260527.6 (#173522) 2026-06-11 18:00:50 +02:00
Robert Resch 29badf6651 Add basic security check to dependency workflow (#171191)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-06-11 17:38:42 +02:00
jasonjhofmann bd58c08eea Add Bluetooth connection to Aranet devices (#173066)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-11 17:03:14 +02:00
orandasoft b69c13477a Refactor iTach YAML remote platform without behavior changes (#173485) 2026-06-11 16:41:13 +02:00
Robert Resch ea5e8e7982 Rephrase aw check requirements (#171676)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-11 16:31:35 +02:00
Duco Sebel dfa40f807e Remove positional message strings when translation_key is set in manual (#173393) 2026-06-11 16:15:26 +02:00
bkobus-bbx fdb15ce2d7 Add support for inputSensor Blebox devices (#169841) 2026-06-11 16:06:07 +02:00
Erwin Douna ee30f6c085 MELCloud Home follow-up PR to refactor small parts (#173515) 2026-06-11 15:52:06 +02:00
Markus Jacobsen d7af8ed2b3 Bump mozart_api to 6.2.0.44.0 (#173514) 2026-06-11 15:50:54 +02:00
119 changed files with 3732 additions and 555 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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 }}
+243 -251
View File
@@ -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 -1
View File
@@ -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:
+4 -2
View File
@@ -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
+2
View File
@@ -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:
+2 -1
View File
@@ -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(
+5
View File
@@ -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:
+29 -3
View File
@@ -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:
+28 -1
View File
@@ -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": {
+11
View File
@@ -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."""
+1 -1
View File
@@ -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,
},
}
+32 -2
View File
@@ -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"]
}
+1
View File
@@ -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,
)
+3 -1
View File
@@ -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()
+112 -25
View File
@@ -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:
+2 -2
View File
@@ -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."
}
+3 -2
View File
@@ -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
+36 -18
View File
@@ -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:
+1 -1
View File
@@ -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)
+5 -1
View File
@@ -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)
)
+12
View File
@@ -0,0 +1,12 @@
{
"entity": {
"binary_sensor": {
"bluetooth_audio": {
"default": "mdi:bluetooth-audio"
},
"headphones": {
"default": "mdi:headphones"
}
}
}
}
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -648,7 +648,7 @@ exclude_lines = [
]
[tool.ruff]
required-version = ">=0.15.15"
required-version = ">=0.15.16"
[tool.ruff.lint]
select = [
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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"
+1
View File
@@ -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"),
)
+11
View File
@@ -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
+109 -3
View File
@@ -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
+4
View File
@@ -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()
+16 -11
View File
@@ -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",
),
]
+1 -1
View File
@@ -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"
)
)
)
+87 -11
View File
@@ -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)
+2 -2
View File
@@ -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(
+3 -3
View File
@@ -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
+7 -7
View File
@@ -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
+77 -7
View File
@@ -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
+89 -7
View File
@@ -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(
+34 -6
View File
@@ -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"]
+2 -2
View File
@@ -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"
)
+1
View File
@@ -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:
+20 -1
View File
@@ -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
+1
View File
@@ -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
+97 -13
View File
@@ -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
+26 -1
View File
@@ -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,
+36 -2
View File
@@ -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"
+48 -3
View File
@@ -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
)
+76 -1
View File
@@ -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