mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 19:51:39 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30d8bf4231 | |||
| 5436d8af9b | |||
| 88adf39ef3 | |||
| 14b14bddf1 | |||
| 3c4a30be6b | |||
| 2988eb4b19 | |||
| 00eef14558 | |||
| d02516dd09 | |||
| aabb6b3d04 | |||
| 50c2c7c4bc | |||
| e81dd426bb | |||
| c4c569c181 | |||
| 6182426132 | |||
| a073cc4f7d | |||
| 07ddc08d84 | |||
| 17673dcf55 | |||
| a864bc1c80 | |||
| a15d80daa2 | |||
| e123b29258 | |||
| 5669a7b602 | |||
| fe358a4a1f | |||
| 3a93d6370b | |||
| 89576f01e6 | |||
| f51895b0c9 | |||
| d0dcbfadaa | |||
| 5e0d3627c2 | |||
| 80c90732a3 | |||
| 16eca3909a | |||
| 1b471da31f | |||
| 7391209f48 | |||
| 0683344079 | |||
| 0b77cf9e4b | |||
| e0a87d966d | |||
| af53d2d082 | |||
| da7fa80e75 | |||
| 6cf1e7fb48 | |||
| 18fa0ac47d | |||
| 4afced1a49 | |||
| 74a4471160 | |||
| 857a3de066 | |||
| 06bf2ff6de | |||
| 6a5dae9cc3 | |||
| 475ebbc028 | |||
| 6e7643e997 | |||
| 1f954cda0d | |||
| 2961fca1b1 | |||
| 106b189206 | |||
| 0387034f4e | |||
| f81b6abca9 | |||
| 43f6e7977e | |||
| 706fea4ec5 | |||
| 74d23503e7 | |||
| 4ca5da2365 | |||
| 53c77ae2ef | |||
| 14968f9d67 |
@@ -8,8 +8,39 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
|
||||
## Follow these steps:
|
||||
1. Use 'gh pr view' to get the PR details and description.
|
||||
2. Use 'gh pr diff' to see all the changes in the PR.
|
||||
3. Review the changes following the `review` skill. It is VERY IMPORTANT to follow the `review` skill instructions.
|
||||
4. Check if all existing review comments have been addressed.
|
||||
3. Analyze the code changes for:
|
||||
- Code quality and style consistency
|
||||
- Potential bugs or issues
|
||||
- Performance implications
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
4. Ensure any existing review comments have been addressed.
|
||||
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
|
||||
|
||||
## Verification:
|
||||
|
||||
- After the review, run parallel subagents for each finding to double check it.
|
||||
- Spawn up to a maximum of 10 parallel subagents at a time.
|
||||
- Gather the results from the subagents and summarize them in the final review comments.
|
||||
|
||||
|
||||
## IMPORTANT:
|
||||
- Just review. DO NOT make any changes
|
||||
- Be constructive and specific in your comments
|
||||
- Suggest improvements where appropriate
|
||||
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
|
||||
- No need to run tests or linters, just review the code changes.
|
||||
- No need to highlight things that are already good.
|
||||
|
||||
## Output format:
|
||||
- List specific comments for each file/line that needs attention.
|
||||
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
|
||||
- Example output:
|
||||
```
|
||||
Overall assessment: request changes.
|
||||
- [CRITICAL] sensor.py:143 - Memory leak
|
||||
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
|
||||
- [SUGGESTION] test_init.py:45 - Improve x variable name
|
||||
```
|
||||
- Make sure to include the file and line number when possible in the bullet points.
|
||||
|
||||
@@ -24,7 +24,6 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: review
|
||||
description: Reviews code changes and provides constructive feedback. Should be used when a review is requested to provide a consistent review behavior and output format. This skill can be used for code reviews in general, not just for GitHub pull requests.
|
||||
---
|
||||
|
||||
# Review Code Changes
|
||||
|
||||
## Analyze the code changes for:
|
||||
- Code quality and style consistency
|
||||
- Potential bugs or issues
|
||||
- Performance implications
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
|
||||
## Verification:
|
||||
- After the review, run parallel subagents for each finding to double-check it.
|
||||
- Spawn up to a maximum of 10 parallel subagents at a time.
|
||||
- Gather the results from the subagents and summarize them in the final review comments.
|
||||
|
||||
## IMPORTANT:
|
||||
- Just review. DO NOT make any changes.
|
||||
- Be constructive and specific in your comments.
|
||||
- Suggest improvements where appropriate.
|
||||
- No need to run tests or linters, just review the code changes.
|
||||
- No need to highlight things that are already good.
|
||||
|
||||
## Output format:
|
||||
- List specific comments for each file/line that needs attention.
|
||||
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
|
||||
- Example output:
|
||||
```
|
||||
Overall assessment: request changes.
|
||||
- [CRITICAL] sensor.py:143 - Memory leak
|
||||
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
|
||||
- [SUGGESTION] test_init.py:45 - Improve x variable name
|
||||
```
|
||||
- Make sure to include the file and line number when possible in the bullet points.
|
||||
@@ -27,7 +27,6 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -292,7 +292,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -344,13 +344,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -471,7 +471,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -518,19 +518,19 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
|
||||
+30
-28
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# 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"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -31,12 +31,12 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - 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@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -59,13 +59,15 @@ permissions: {}
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs: pre_activation
|
||||
needs:
|
||||
- extract_pr_number
|
||||
- pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
@@ -88,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -131,7 +133,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -189,20 +191,20 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -231,12 +233,12 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -321,6 +323,7 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: "gh-aw-copilot-${{ github.workflow }}"
|
||||
@@ -349,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -369,7 +372,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Create gh-aw temp directory
|
||||
@@ -450,9 +453,9 @@ jobs:
|
||||
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_eaae5443153d0b45_EOF'
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -644,7 +647,7 @@ jobs:
|
||||
|
||||
mkdir -p /home/runner/.copilot
|
||||
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
|
||||
cat << GH_AW_MCP_CONFIG_d99df59573a98681_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -654,7 +657,7 @@ jobs:
|
||||
"GITHUB_HOST": "\${GITHUB_SERVER_URL}",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
|
||||
"GITHUB_READ_ONLY": "1",
|
||||
"GITHUB_TOOLSETS": "repos,pull_requests"
|
||||
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
|
||||
},
|
||||
"guard-policies": {
|
||||
"allow-only": {
|
||||
@@ -688,7 +691,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_d99df59573a98681_EOF
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -958,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1097,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1124,7 +1127,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1281,7 +1284,6 @@ jobs:
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -1323,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1381,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
network:
|
||||
allowed:
|
||||
@@ -13,7 +14,7 @@ network:
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [repos, pull_requests]
|
||||
toolsets: [default, actions]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
@@ -43,7 +44,7 @@ jobs:
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
@@ -82,289 +83,296 @@ description: >
|
||||
|
||||
# Check requirements (AW)
|
||||
|
||||
You are a code-review assistant for Home Assistant. The deterministic
|
||||
stage already evaluated every check it can and produced an artifact at
|
||||
`/tmp/gh-aw/deterministic/results.json`. Your only job is to resolve any
|
||||
`needs_agent` checks and post the rendered comment.
|
||||
You are a code review assistant for the Home Assistant project. The
|
||||
deterministic stage has already evaluated every check it can on its own
|
||||
and produced an artifact containing the PR number, per-package check
|
||||
results, and a pre-rendered comment with placeholders. **Your only job is
|
||||
to read that artifact, resolve any `needs_agent` checks, and post the
|
||||
final comment.**
|
||||
|
||||
## Step 1 — Read the artifact
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
|
||||
Read the JSON directly for the full schema. Key fields:
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
|
||||
- `pr_number`, `needs_agent` (bool), `packages[]`, `rendered_comment`.
|
||||
- Each `package`: `name`, `old_version` (`null` if new), `new_version`,
|
||||
`repo_url`, `publisher_kind`, `checks` (keyed by check-kind, each
|
||||
with `status` of `pass`/`warn`/`fail`/`needs_agent` and `details`).
|
||||
- `rendered_comment` contains, for each `needs_agent` check, two
|
||||
placeholders to replace:
|
||||
- `{{CHECK_CELL:<pkg>:<kind>}}` → exactly one of `✅`, `☑️`, `⚠️`, `❌`. The
|
||||
**`security`** check kind uses `☑️` instead of `✅` for the success
|
||||
case — see its section below for why.
|
||||
- `{{CHECK_DETAIL:<pkg>:<kind>}}` → `<icon> <one-line explanation>`
|
||||
(the bullet's `- **<label>**:` prefix is already rendered; replace
|
||||
only the placeholder).
|
||||
The JSON has this shape:
|
||||
|
||||
Do not modify other content in `rendered_comment`, do not re-evaluate
|
||||
deterministic checks, do not add or remove packages. If `needs_agent`
|
||||
is `false`, emit `rendered_comment` unchanged.
|
||||
- `pr_number` — the PR being checked. The `add_comment` safe-output is
|
||||
already targeted at this PR (a pre-job extracts `pr_number` from the
|
||||
artifact and the workflow wires it into the safe-output config via
|
||||
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
|
||||
set `item_number` yourself** — just emit `add_comment` with the
|
||||
rendered body.
|
||||
- `needs_agent` — `true` iff any package's check needs resolution.
|
||||
- `packages[]` — one entry per changed package. Each entry has:
|
||||
- `name`, `old_version` (`null` for a newly added package; otherwise the
|
||||
previous pin), `new_version`, `repo_url`, `publisher_kind`.
|
||||
- `checks` — a dict keyed by **check kind** (string). Each value has a
|
||||
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
|
||||
- `rendered_comment` — the final PR comment body, already rendered. For
|
||||
every check whose status is `needs_agent` it contains two placeholders
|
||||
you must replace:
|
||||
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
|
||||
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
|
||||
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
|
||||
in the package's `<details>` block. Replace with
|
||||
`<icon> <one-line explanation>` (the bullet's leading
|
||||
`- **<label>**:` is already rendered — replace only the placeholder).
|
||||
|
||||
You **must not** modify any other content in `rendered_comment`. Do not
|
||||
re-evaluate checks that already have a deterministic status. Do not add
|
||||
or remove packages.
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
|
||||
For each `(package, check_kind)` with `status == "needs_agent"`, find
|
||||
the matching `### Check kind: <check_kind>` section below and follow
|
||||
it. If no section matches, emit a single `add_comment` with:
|
||||
For each `package` in `packages`:
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
For each `(check_kind, result)` in `package.checks` where
|
||||
`result.status == "needs_agent"`:
|
||||
|
||||
❌ Internal error: deterministic artifact contains an unknown check kind
|
||||
(`<check_kind>` on `<pkg>`).
|
||||
```
|
||||
1. Look up `## Check kind: <check_kind>` in the **Check instructions**
|
||||
section below.
|
||||
2. **If no matching section exists**: emit a single `add_comment` whose
|
||||
body is:
|
||||
|
||||
Then stop. Do not improvise a verdict.
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
❌ Internal error: the deterministic artifact contains a check kind
|
||||
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
|
||||
instructions for. Update `.github/workflows/check-requirements.md`
|
||||
to add a matching `## Check kind: <check_kind>` section, or remove
|
||||
the kind from the deterministic stage.
|
||||
```
|
||||
|
||||
Then stop. **Do not improvise** a verdict for an unknown check kind.
|
||||
3. Otherwise, follow the instructions in that section. They tell you
|
||||
which icon (✅/⚠️/❌) and one-line explanation to produce.
|
||||
|
||||
## Step 3 — Post the comment
|
||||
|
||||
Replace every placeholder with the resolved value and emit
|
||||
`rendered_comment` via `add_comment`. Preserve the leading
|
||||
`<!-- requirements-check -->` marker. The PR target is already wired;
|
||||
do not pass `item_number`.
|
||||
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
|
||||
in `rendered_comment` with the resolved value.
|
||||
2. Emit the resulting markdown using `add_comment` — set `body` to the
|
||||
merged `rendered_comment` verbatim (the leading
|
||||
`<!-- requirements-check -->` marker must be preserved). The PR
|
||||
target is already set by the workflow; do not pass `item_number`.
|
||||
|
||||
If the artifact's top-level `needs_agent` is `false` (no checks need
|
||||
you), emit `rendered_comment` unchanged.
|
||||
|
||||
## Check instructions
|
||||
|
||||
### Check kind: `repo_public`
|
||||
|
||||
`web-fetch` GET `package.repo_url`.
|
||||
- 200 + public repo page → ✅ `<repo_url> is publicly accessible.`
|
||||
- 4xx/5xx or login redirect → ❌ `Source repository at <repo_url> is
|
||||
not publicly accessible. Home Assistant requires dependencies to
|
||||
have publicly available source code.`
|
||||
- Otherwise → ⚠️ with a one-line description.
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
|
||||
If ❌, also mark this package's `release_pipeline` and `async_blocking`
|
||||
cells/details as `—` and explain `Skipped because the source
|
||||
repository is not publicly accessible.`.
|
||||
1. Read `package.repo_url`.
|
||||
2. Use the `web-fetch` tool to GET that URL.
|
||||
3. Decide the verdict:
|
||||
- HTTP 200, returns a public repository page → ✅
|
||||
`<repo_url> is publicly accessible.`
|
||||
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
|
||||
❌ `Source repository at <repo_url> is not publicly accessible.
|
||||
Home Assistant requires all dependencies to have publicly available
|
||||
source code.`
|
||||
- Any other inconclusive result → ⚠️ with a one-line description.
|
||||
|
||||
If `repo_public` resolves to ❌ for a package, **also** mark that
|
||||
package's `release_pipeline` and `async_blocking` cells/details as `—`
|
||||
(em dash) and explain `Skipped because the source repository is not
|
||||
publicly accessible.` — neither check can be performed without a public
|
||||
repo.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
Fetch the PR body via the `pull_requests` MCP using `pr_number`. Extract URLs.
|
||||
Verify the PR description contains the right link for the change.
|
||||
|
||||
- **New package** (`old_version == null`): body must contain a URL
|
||||
pointing at `repo_url`'s `owner/repo` on the same host (any
|
||||
sub-path OK). PyPI is not sufficient.
|
||||
- ✅ if present; otherwise ❌ `PR description must link to the
|
||||
source repository at <repo_url>. A PyPI page link is not
|
||||
sufficient.`
|
||||
- **Version bump**: body must contain a URL on the same host as
|
||||
`repo_url` that mentions **both** `old_version` and `new_version`
|
||||
(compare URL, changelog, release page).
|
||||
- ✅ if present and versions match; otherwise ❌ `PR description
|
||||
should link to a changelog or compare URL on <repo_url> that
|
||||
mentions both <old_version> and <new_version>.`
|
||||
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
|
||||
field from the artifact.
|
||||
2. Extract all URLs from the body.
|
||||
3. For a **new package** (`package.old_version` is `null`):
|
||||
- The PR body must contain a URL that points at `package.repo_url`
|
||||
(any sub-path of the same `owner/repo` on the same host is
|
||||
acceptable). A PyPI link is **not** sufficient.
|
||||
- ✅ if such a URL is present.
|
||||
- ❌ otherwise:
|
||||
`PR description must link to the source repository at <repo_url>.
|
||||
A PyPI page link is not sufficient.`
|
||||
4. For a **version bump** (`package.old_version` is not `null`):
|
||||
- The PR body must contain a URL on the same host as
|
||||
`package.repo_url` that references **both** `package.old_version`
|
||||
and `package.new_version` (e.g. a GitHub compare URL
|
||||
`compare/vX...vY`, a release / changelog URL containing both
|
||||
versions, etc.).
|
||||
- ✅ if such a URL is present and the versions match the actual bump.
|
||||
- ❌ otherwise:
|
||||
`PR description should link to a changelog or compare URL on
|
||||
<repo_url> that mentions both <old_version> and <new_version>.`
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
|
||||
Inspect the upstream's publish-to-PyPI CI. Host-specific lookup, same
|
||||
rubric:
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
|
||||
1. Locate the publish workflow / job (name or filename contains
|
||||
`release`, `publish`, `pypi`, or `deploy`).
|
||||
- GitHub: list `.github/workflows/` via the `repos` MCP, pick the
|
||||
promising file by name, fetch its contents.
|
||||
- GitLab: fetch `.gitlab-ci.yml` from the default ref via
|
||||
`https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
- Other hosts: `web-fetch` an obvious CI config
|
||||
(`.circleci/config.yml`, `bitbucket-pipelines.yml`, etc.).
|
||||
2. Apply this rubric:
|
||||
- **Trigger**: tag push / `release: published` / protected branch —
|
||||
not solely manual dispatch without an environment guard.
|
||||
- **Credentials**: OIDC (`id-token: write` +
|
||||
`pypa/gh-action-pypi-publish` or equivalent) preferred; static
|
||||
`PYPI_TOKEN` from a CI secret acceptable for a bump.
|
||||
- **No bypass**: no ungated `twine upload` / `pip upload`.
|
||||
3. Verdict:
|
||||
- ✅ — OIDC + sane triggers + no bypass.
|
||||
- ⚠️ — static token on a bump, details unclear, or
|
||||
non-GitHub/GitLab host with limited CI visibility.
|
||||
- ❌ — static token on a new package, or manual-only triggers
|
||||
without environment protection.
|
||||
For each package needing inspection, determine the source repository
|
||||
host from `package.repo_url`, then apply the corresponding checklist.
|
||||
|
||||
#### GitHub repositories (`github.com`)
|
||||
|
||||
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
|
||||
2. Identify any workflow whose name or filename suggests publishing to
|
||||
PyPI (`release`, `publish`, `pypi`, or `deploy`).
|
||||
3. Fetch the workflow file and check:
|
||||
- **Trigger sanity**: triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job —
|
||||
**not** solely `workflow_dispatch` with no environment-protection
|
||||
guard.
|
||||
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
|
||||
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
|
||||
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
|
||||
- **No manual upload bypass**: no ungated `twine upload` or
|
||||
`pip upload`.
|
||||
4. Verdict:
|
||||
- ✅ if OIDC + sane triggers + no bypass.
|
||||
- ⚠️ if static token but version bump, or details unclear.
|
||||
- ❌ if static token on a new package, or only-manual triggers with
|
||||
no environment protection.
|
||||
|
||||
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
|
||||
2. Fetch `.gitlab-ci.yml` via
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
3. Apply the same conceptual checks: tag-only / protected-branch
|
||||
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
|
||||
ungated `twine upload`. Same verdict rules as GitHub.
|
||||
|
||||
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
|
||||
|
||||
1. Use `web-fetch` to retrieve any visible CI configuration
|
||||
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
|
||||
`bitbucket-pipelines.yml`, `.builds/*.yml`).
|
||||
2. Apply the conceptual checks: automated triggers, CI-injected
|
||||
credentials, no manual `twine upload`.
|
||||
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
|
||||
inspected; hosting provider is not GitHub or GitLab.`
|
||||
|
||||
### Check kind: `async_blocking`
|
||||
|
||||
Verify the dependency does not call blocking APIs inside `async def`
|
||||
bodies. Home Assistant runs on a single asyncio loop, so blocking
|
||||
calls from the async surface stall the whole loop. A purely sync
|
||||
library is fine — integrations wrap its calls in an executor.
|
||||
Verify whether the dependency performs blocking I/O inside async code
|
||||
paths. Home Assistant runs on a single asyncio event loop, so a library
|
||||
that exposes an `async` surface must not call blocking APIs from inside
|
||||
its `async def` functions — that stalls the whole loop. A purely sync
|
||||
library is fine: Home Assistant integrations are expected to wrap such
|
||||
calls in an executor.
|
||||
|
||||
**Mode** (decided by `old_version`):
|
||||
- `null` → new package: review the entire current source tree.
|
||||
- string → version bump: review only the diff between the two tags.
|
||||
Blocking calls already present in `old_version` are not regressions.
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
|
||||
**Step 1 — async surface?**
|
||||
- `old_version` is `null` → **new package**: review the *entire current
|
||||
source tree*. Nothing about this dependency has been vetted before.
|
||||
- `old_version` is a string → **version bump**: review only the *diff
|
||||
between `old_version` and `new_version`*. The previous version was
|
||||
already accepted, so blocking calls that were present in
|
||||
`old_version` are not regressions; report only what `new_version`
|
||||
introduces.
|
||||
|
||||
Fetch `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` at the
|
||||
tag matching `new_version` (try `v{version}`, `{version}`,
|
||||
`release-{version}` — at most three attempts). Use the `repos` MCP for
|
||||
github.com, `web-fetch` otherwise.
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
|
||||
If sync-only (no `async def` in public modules; no
|
||||
asyncio/aiohttp/httpx/anyio in deps; no `Framework :: AsyncIO`
|
||||
classifier) → ✅ `Sync-only library; Home Assistant integrations must
|
||||
wrap calls in an executor.` (Same verdict for both modes.)
|
||||
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
|
||||
(other hosts) on `package.repo_url`. Always inspect the tag /
|
||||
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
|
||||
|
||||
**Step 2 — review the surface**
|
||||
- Locate the top-level package directory (usually named after the
|
||||
import name, often equal or close to `package.name`).
|
||||
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
|
||||
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
|
||||
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
|
||||
example in the README).
|
||||
- Grep the package source for `async def`. A handful of `async def`
|
||||
entries in the public modules is enough to treat the library as
|
||||
having an async surface.
|
||||
|
||||
- New package: grep public modules for `async def`, inspect each
|
||||
async body and transitive helpers.
|
||||
- Bump: fetch the compare diff
|
||||
(`/repos/{owner}/{repo}/compare/{old}...{new}` on GitHub, equivalent
|
||||
on GitLab/other hosts). Only flag patterns on **added** lines that
|
||||
are inside or reachable from `async def`. If no tag format resolves,
|
||||
fall back to a full review and note that the diff was unavailable.
|
||||
If the library is **sync-only** (no `async def` in its public modules
|
||||
and no async framework dependency) → ✅
|
||||
`Sync-only library; Home Assistant integrations must wrap calls in an
|
||||
executor.` *This verdict is the same in both modes.*
|
||||
|
||||
**Blocking patterns to flag inside `async def`:**
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(`, `pycurl`.
|
||||
- `time.sleep(` (use `await asyncio.sleep(`).
|
||||
- Sync sockets/SSL: bare `socket.socket` I/O, `ssl.wrap_socket`,
|
||||
Inspect **every `async def` in the public modules** for blocking
|
||||
patterns. Walk transitively into helpers the async functions call.
|
||||
|
||||
#### Step 2b — Mode: version bump (`old_version` is a string)
|
||||
|
||||
Fetch the diff between the two tags and review **only changed lines**:
|
||||
|
||||
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
|
||||
the `github` MCP tool, or
|
||||
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
|
||||
via `web-fetch`. Try the common tag formats in order until one
|
||||
resolves: `v{version}`, `{version}`, `release-{version}`.
|
||||
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
|
||||
- Other hosts: use the project's equivalent compare URL via
|
||||
`web-fetch`.
|
||||
|
||||
If neither tag format resolves on the host, fall back to a full review
|
||||
(Step 2a) and mention in the detail that the diff was unavailable.
|
||||
|
||||
When reviewing the diff, only flag blocking patterns that appear in
|
||||
**added lines** *inside or reachable from* an `async def`. A blocking
|
||||
call that existed in `old_version` and is unchanged is not a regression
|
||||
for this bump.
|
||||
|
||||
#### Step 3 — Blocking patterns to look for
|
||||
|
||||
In both modes, the patterns to flag inside `async def` bodies are:
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
|
||||
`AsyncClient`), `pycurl`.
|
||||
- `time.sleep(` (must be `await asyncio.sleep(`).
|
||||
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
|
||||
blocking `select.select`.
|
||||
- File I/O on the request path: `open(` /
|
||||
`pathlib.Path.read_*` / `.write_*` for non-trivial sizes (small
|
||||
one-shot reads during import are OK).
|
||||
- Sync DB drivers: `sqlite3`, `psycopg2`, `pymysql`, sync `pymongo` /
|
||||
`redis.Redis`.
|
||||
- `subprocess.run` / `subprocess.call` / `os.system`.
|
||||
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
|
||||
non-trivial sizes (small one-shot reads during import are
|
||||
acceptable; reads/writes on the request path are not — prefer
|
||||
`aiofiles` / executor).
|
||||
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
|
||||
`pymongo` (sync client), `redis.Redis` (sync client).
|
||||
- `subprocess.run` / `subprocess.call` / `os.system` (must be
|
||||
`asyncio.create_subprocess_*`).
|
||||
|
||||
Calls dispatched to an executor (`run_in_executor`,
|
||||
`asyncio.to_thread`, `anyio.to_thread.run_sync`) do **not** count as
|
||||
blocking.
|
||||
A call that is clearly dispatched to an executor
|
||||
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
|
||||
does NOT count as blocking.
|
||||
|
||||
**Verdict:**
|
||||
#### Step 4 — Verdict
|
||||
|
||||
- ✅ — no offending pattern. Bumps: phrase as `No new blocking calls
|
||||
introduced in {old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking only in sync helpers the async API never calls, or
|
||||
clearly off the hot path (e.g. one-shot pre-loop setup). Cite at
|
||||
least one `<file>:<line>` and say why it's not hot.
|
||||
- ❌ — blocking call reachable from a public `async def` on the
|
||||
request/polling path (bump: introduced or moved onto the hot path
|
||||
by this version). Cite the offending `<file>:<line>` as a clickable
|
||||
link on the repo host.
|
||||
|
||||
### Check kind: `security`
|
||||
|
||||
**Baseline** scan of the upstream source for obvious supply-chain red
|
||||
flags — a cheap first pass, **not** a security review or malware audit.
|
||||
A clean result means "nothing obvious stood out", not "this package is
|
||||
safe". The success icon is `☑️` — **never** `✅` — so a passing scan is
|
||||
not read as an endorsement.
|
||||
|
||||
If `repo_public` resolves to ❌ for the same package, mark `security`'s
|
||||
cell and detail as `—` and explain `Skipped because the source
|
||||
repository is not publicly accessible.` — the source cannot be fetched.
|
||||
|
||||
**Step 1 — Fetch a representative slice**
|
||||
|
||||
Locate the source from `package.repo_url`.
|
||||
|
||||
- GitHub: resolve the default branch (`GET /repos/{owner}/{repo}`), list
|
||||
the tree (`GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1`),
|
||||
find the module dir (`{name}/` or `src/{name}/`, normalising `-` ↔ `_`).
|
||||
- GitLab: equivalent REST calls. Other hosts: `web-fetch` raw file URLs.
|
||||
|
||||
Fetch the **raw contents** of `setup.py` (install-time code runs on every
|
||||
consumer), `pyproject.toml` (`[build-system]` / custom backend), the
|
||||
package's `__init__.py`, and co — prioritising `entry_points` targets, plus any name suggesting
|
||||
bootstrap / loader / self-update (`update*.py`, `loader*.py`,
|
||||
`bootstrap*.py`, `_native.py`, `_post_install*.py`, …).
|
||||
|
||||
If the tree is too large for the API budget, inspect at least `setup.py`,
|
||||
`pyproject.toml`, and `__init__.py`, then return ⚠️ noting the partial scan.
|
||||
|
||||
**Step 2 — Patterns to flag**
|
||||
|
||||
Reason from principles, not a fixed checklist: for each file ask *would a
|
||||
well-behaved library doing what this package's PyPI description claims
|
||||
need to do this?* If "no" or "unclear", record a finding. The categories
|
||||
describe the **shape** of concerning behavior; the named APIs, filenames,
|
||||
and keys are illustrative — treat any equivalent construct (including ones
|
||||
that did not exist when this was written) the same way.
|
||||
|
||||
For every finding include the file path, line number, a snippet
|
||||
(≤ 120 chars), a permalink
|
||||
(`https://github.com/{owner}/{repo}/blob/{sha}/{path}#L{line}` or the
|
||||
GitLab equivalent), and one sentence on why it is out of scope.
|
||||
|
||||
1. **Reaches into Home Assistant internals.** A library should touch HA
|
||||
only through its documented Python API — never the `config_dir`
|
||||
filesystem or internal auth / session state. Flag code that opens,
|
||||
reads, writes, or resolves paths to artifacts it does not own
|
||||
(top-level YAML it did not create, anything under `.storage/`, other
|
||||
integrations' files) or reads tokens / refresh tokens / auth providers
|
||||
(e.g. `secrets.yaml`, `.storage/auth*`, `hass.auth`). The principle is
|
||||
*out-of-scope access*, not a static list of names.
|
||||
2. **Network input flows into an execution sink (download-and-execute).**
|
||||
Flag any data-flow from a network response body (any HTTP / WebSocket /
|
||||
raw-socket client, sync or async) to an execution sink: `exec`, `eval`,
|
||||
`compile`, `marshal.loads`, `pickle.loads`, `types.FunctionType`,
|
||||
`importlib.util.spec_from_loader`, `subprocess.*`, `os.system`, shell
|
||||
pipelines (`curl … | sh`), or a file later imported / executed — plus
|
||||
package-manager calls (`pip install` / `download`) with args resolved
|
||||
from network responses at runtime.
|
||||
3. **Build / install-time code is non-deterministic or non-local.**
|
||||
`setup.py`, `setup.cfg` `cmdclass`, custom PEP 517 backends, and other
|
||||
build hooks must only compile and copy files shipped in the sdist. Flag
|
||||
build-stage code that opens a socket, shells out, writes outside the
|
||||
build / install tree, or pulls a build backend not on PyPI (Git URL /
|
||||
local path).
|
||||
4. **Reads secrets and combines them with an egress path.** The shape is
|
||||
*secret-source → outbound-channel*. Flag code that reads credential
|
||||
material (token-like env vars, credential files under the user's home,
|
||||
OS keychain APIs, browser-profile dirs, HA token stores) **and** in the
|
||||
same path sends it to a destination the package needn't talk to.
|
||||
Reading or sending alone is not enough — the *combination* is the signal.
|
||||
5. **Hides what it does.** Flag opaque data flowing into an execution
|
||||
sink: large encoded / compressed / hex strings (`base64`, `codecs`,
|
||||
`zlib`, `lzma`, `bytes.fromhex`, or any equivalent) passed to `exec` /
|
||||
`eval` / `compile` / `__import__`; identifiers assembled at runtime
|
||||
then imported; or any construct whose evident purpose is to make the
|
||||
behavior unreadable.
|
||||
6. **Hard-coded network destination off-purpose.** Flag outbound URLs or
|
||||
hosts absent from the package's PyPI `project_urls` with no obvious
|
||||
connection to its function — short-link / paste services, ephemeral
|
||||
tunnels, raw IPs, non-default ports against unknown hosts — and any
|
||||
network call at module top-level / `__init__.py` (runs on import for
|
||||
every consumer).
|
||||
|
||||
A clearly out-of-scope behavior that fits none of the above: flag under
|
||||
the closest category and explain. The categories guide reasoning, not bound it.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Aggregate the findings into one of:
|
||||
|
||||
- `☑️ Baseline scan found nothing obvious in <list of inspected files>.
|
||||
This is not a security review — only the cheap checks were run.`
|
||||
Use `☑️` (**not** `✅`) so a passing scan is not read as an endorsement.
|
||||
- `⚠️ <one-line summary>` — patterns with plausible legitimate uses;
|
||||
include path / line / snippet / permalink per match for the reviewer.
|
||||
- `❌ <one-line summary>` — patterns with no legitimate explanation
|
||||
(install-time network execution, decode-and-exec of opaque blobs, reads
|
||||
of `secrets.yaml` / `.storage/auth*`, token exfiltration to an external
|
||||
host); same detail.
|
||||
|
||||
Be precise. False positives are expected — when in doubt prefer `⚠️` with
|
||||
context over `❌`. This check is informational and never blocks the
|
||||
workflow on its own; a human reviewer decides whether to merge.
|
||||
- ✅ — no offending blocking pattern in the surface being reviewed
|
||||
(whole tree for a new package, added lines for a bump). For a bump,
|
||||
phrase the detail as `No new blocking calls introduced in
|
||||
{old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking calls exist only in sync helpers that the async API
|
||||
does not call, or only on a clearly non-hot path (e.g. one-shot
|
||||
setup before the event loop is running). Cite at least one
|
||||
`<file>:<line>` and explain why it is not on the hot path.
|
||||
- ❌ — a blocking call is reachable from an `async def` that is part
|
||||
of the public API on the request / polling path (for a bump: the
|
||||
call was introduced or moved onto the hot path by this version).
|
||||
Cite the offending `<file>:<line>` as a clickable link on the repo
|
||||
host so the contributor can jump to it.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive; reference the inspected file by URL when useful.
|
||||
- Comment dedup is handled by gh-aw's `add_comment` safe-output via
|
||||
the `<!-- requirements-check -->` marker.
|
||||
- If `/tmp/gh-aw/deterministic/results.json` is missing (upstream
|
||||
cancelled/failed), emit nothing — the post-step verification is
|
||||
gated and won't complain.
|
||||
- Be constructive and helpful. Reference the inspected workflow / CI
|
||||
file by URL where useful so the contributor can fix the issue.
|
||||
- The dedup of the requirements-check comment is handled by gh-aw's
|
||||
`add_comment` safe-output via the `<!-- requirements-check -->`
|
||||
marker on the first line of `rendered_comment`.
|
||||
- If the deterministic workflow concluded with a non-success status,
|
||||
this workflow's `if:` guard on `Download deterministic-results
|
||||
artifact` skipped the download. If you find no file at
|
||||
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
|
||||
verification is also gated and will not complain.
|
||||
|
||||
+22
-22
@@ -39,7 +39,7 @@ on:
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.7"
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Generate partial Python venv restore key
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Register problem matchers
|
||||
@@ -291,7 +291,7 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
@@ -318,7 +318,7 @@ jobs:
|
||||
- script/hassfest/docker/Dockerfile
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Register hadolint problem matcher
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -404,7 +404,7 @@ jobs:
|
||||
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up uv
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: ${{ steps.read-uv-version.outputs.version }}
|
||||
- name: Create Python virtual environment
|
||||
@@ -469,7 +469,7 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
@@ -512,7 +512,7 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
@@ -548,7 +548,7 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
@@ -576,7 +576,7 @@ jobs:
|
||||
&& github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Dependency review
|
||||
@@ -603,7 +603,7 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -654,7 +654,7 @@ jobs:
|
||||
|| github.event.inputs.pylint-only == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
@@ -707,7 +707,7 @@ jobs:
|
||||
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
@@ -758,7 +758,7 @@ jobs:
|
||||
|| github.event.inputs.mypy-only == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
@@ -825,7 +825,7 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
@@ -889,7 +889,7 @@ jobs:
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
@@ -1030,7 +1030,7 @@ jobs:
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
@@ -1179,7 +1179,7 @@ jobs:
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
@@ -1317,7 +1317,7 @@ jobs:
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
@@ -1355,7 +1355,7 @@ jobs:
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
@@ -1476,7 +1476,7 @@ jobs:
|
||||
- pytest-partial
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
|
||||
@@ -23,16 +23,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
+67
-25
@@ -20,36 +20,22 @@ jobs:
|
||||
permissions:
|
||||
issues: write # To label and close stale issues
|
||||
pull-requests: write # To label and close stale PRs
|
||||
actions: write # To delete stalebot state
|
||||
steps:
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 60 day stale policy for PRs
|
||||
# Used for:
|
||||
# - PRs
|
||||
# - No PRs marked as no-stale
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
- name: 60 days stale PRs policy and 90 days stale issue policy
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
operations-per-run: 150
|
||||
remove-stale-when-updated: true
|
||||
operations-per-run: 350
|
||||
# pr policy
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 7
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "no-stale"
|
||||
stale-pr-message: >
|
||||
@@ -62,9 +48,65 @@ jobs:
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
# issue policy
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
# This is only used for issues.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest Home Assistant version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
# The 30 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues that are pending more information (incomplete issues)
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
days-before-stale: 14
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted"
|
||||
stale-issue-message: >
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.15
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -96,7 +96,6 @@ homeassistant.components.aprs.*
|
||||
homeassistant.components.apsystems.*
|
||||
homeassistant.components.aqualogic.*
|
||||
homeassistant.components.aquostv.*
|
||||
homeassistant.components.aqvify.*
|
||||
homeassistant.components.aranet.*
|
||||
homeassistant.components.arcam_fmj.*
|
||||
homeassistant.components.arris_tg2492lg.*
|
||||
@@ -287,7 +286,6 @@ homeassistant.components.huawei_lte.*
|
||||
homeassistant.components.humidifier.*
|
||||
homeassistant.components.husqvarna_automower.*
|
||||
homeassistant.components.huum.*
|
||||
homeassistant.components.hvv_departures.*
|
||||
homeassistant.components.hydrawise.*
|
||||
homeassistant.components.hyperion.*
|
||||
homeassistant.components.hypontech.*
|
||||
|
||||
Generated
+2
-16
@@ -162,8 +162,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH
|
||||
/homeassistant/components/aquacell/ @Jordi1990
|
||||
/tests/components/aquacell/ @Jordi1990
|
||||
/homeassistant/components/aqvify/ @astrandb
|
||||
/tests/components/aqvify/ @astrandb
|
||||
/homeassistant/components/aranet/ @aschmitz @thecode @anrijs
|
||||
/tests/components/aranet/ @aschmitz @thecode @anrijs
|
||||
/homeassistant/components/arcam_fmj/ @elupus
|
||||
@@ -455,8 +453,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/ecovacs/ @mib1185 @edenhaus @Augar
|
||||
/homeassistant/components/ecowitt/ @pvizeli
|
||||
/tests/components/ecowitt/ @pvizeli
|
||||
/homeassistant/components/edifier_infrared/ @abmantis
|
||||
/tests/components/edifier_infrared/ @abmantis
|
||||
/homeassistant/components/efergy/ @tkdrob
|
||||
/tests/components/efergy/ @tkdrob
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
@@ -505,8 +501,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
|
||||
/homeassistant/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/tests/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50 @roberty99
|
||||
@@ -576,8 +570,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/flo/ @dmulcahey
|
||||
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
|
||||
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
|
||||
/homeassistant/components/fluss/ @fluss @Marcello17
|
||||
/tests/components/fluss/ @fluss @Marcello17
|
||||
/homeassistant/components/fluss/ @fluss
|
||||
/tests/components/fluss/ @fluss
|
||||
/homeassistant/components/flux_led/ @icemanch
|
||||
/tests/components/flux_led/ @icemanch
|
||||
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
|
||||
@@ -724,8 +718,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
/homeassistant/components/hegel/ @boazca
|
||||
/tests/components/hegel/ @boazca
|
||||
/homeassistant/components/helty/ @ebaschiera
|
||||
/tests/components/helty/ @ebaschiera
|
||||
/homeassistant/components/heos/ @andrewsayre
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
@@ -844,8 +836,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/imgw_pib/ @bieniu
|
||||
/homeassistant/components/immich/ @mib1185
|
||||
/tests/components/immich/ @mib1185
|
||||
/homeassistant/components/imou/ @Imou-OpenPlatform
|
||||
/tests/components/imou/ @Imou-OpenPlatform
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
@@ -947,8 +937,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/kiosker/ @Claeysson
|
||||
/homeassistant/components/kitchen_sink/ @home-assistant/core
|
||||
/tests/components/kitchen_sink/ @home-assistant/core
|
||||
/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer
|
||||
/tests/components/klik_aan_klik_uit/ @Phunkafizer
|
||||
/homeassistant/components/kmtronic/ @dgomes
|
||||
/tests/components/kmtronic/ @dgomes
|
||||
/homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
|
||||
@@ -1086,8 +1074,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/mediaroom/ @dgomes
|
||||
/homeassistant/components/melcloud/ @erwindouna
|
||||
/tests/components/melcloud/ @erwindouna
|
||||
/homeassistant/components/melcloud_home/ @erwindouna
|
||||
/tests/components/melcloud_home/ @erwindouna
|
||||
/homeassistant/components/melissa/ @kennedyshead
|
||||
/tests/components/melissa/ @kennedyshead
|
||||
/homeassistant/components/melnor/ @vanstinator
|
||||
|
||||
@@ -92,7 +92,8 @@ def _extract_backup(
|
||||
):
|
||||
ostf.tar.extractall(
|
||||
path=Path(tempdir, "extracted"),
|
||||
filter="tar",
|
||||
members=securetar.secure_path(ostf.tar),
|
||||
filter="fully_trusted",
|
||||
)
|
||||
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
||||
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
||||
@@ -118,7 +119,8 @@ def _extract_backup(
|
||||
) as istf:
|
||||
istf.extractall(
|
||||
path=Path(tempdir, "homeassistant"),
|
||||
filter="tar",
|
||||
members=securetar.secure_path(istf),
|
||||
filter="fully_trusted",
|
||||
)
|
||||
if restore_content.restore_homeassistant:
|
||||
keep = list(KEEP_BACKUPS)
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
PERCENTAGE,
|
||||
UV_INDEX,
|
||||
UnitOfIrradiance,
|
||||
@@ -46,8 +47,6 @@ from .coordinator import (
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
PARTS_PER_CUBIC_METER = "p/m³"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AccuWeatherSensorDescription(SensorEntityDescription):
|
||||
@@ -82,7 +81,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Grass",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -108,7 +107,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Mold",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -117,7 +116,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Ragweed",
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
@@ -185,7 +184,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Tree",
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
|
||||
@@ -116,6 +116,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
|
||||
"""Migrate old config entry."""
|
||||
|
||||
# This means the user has downgraded from a future version
|
||||
if entry.version > 2:
|
||||
return False
|
||||
|
||||
# 1.1 Migrate config_entry to add advanced ssl settings
|
||||
if entry.version == 1 and entry.minor_version == 1:
|
||||
new_minor_version = 2
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["airos==0.6.8"]
|
||||
"requirements": ["airos==0.6.5"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -29,12 +25,6 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
|
||||
connections={
|
||||
(
|
||||
CONNECTION_NETWORK_MAC,
|
||||
format_mac(self.coordinator.data["status"]["mac_address"]),
|
||||
)
|
||||
},
|
||||
manufacturer="AirVisual",
|
||||
model=self.coordinator.data["status"]["model"],
|
||||
name=self.coordinator.data["settings"]["node_name"],
|
||||
|
||||
@@ -65,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
if entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 3:
|
||||
if CONF_SITE in entry.data:
|
||||
# Site in data (wrong place), just move to login data
|
||||
|
||||
@@ -12,18 +12,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
"access_token",
|
||||
"adp_token",
|
||||
"device_private_key",
|
||||
"refresh_token",
|
||||
"store_authentication_cookie",
|
||||
"title",
|
||||
"website_cookies",
|
||||
}
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.3"]
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> b
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version == 1:
|
||||
new_data = {**entry.data}
|
||||
if CONF_DEVICES in new_data:
|
||||
|
||||
@@ -178,6 +178,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Coordinator for the Anthropic integration."""
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import anthropic
|
||||
|
||||
@@ -19,12 +20,15 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
|
||||
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
|
||||
|
||||
|
||||
_model_short_form = re.compile(r"[^\d]-\d$")
|
||||
|
||||
|
||||
@callback
|
||||
def model_alias(model_id: str) -> str:
|
||||
"""Resolve alias from versioned model name."""
|
||||
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
|
||||
model_id = model_id[:-9]
|
||||
if model_id.endswith("-4"):
|
||||
if _model_short_form.search(model_id):
|
||||
return model_id + "-0"
|
||||
return model_id
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
@@ -113,7 +114,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import JsonArrayType, JsonObjectType
|
||||
|
||||
from .const import (
|
||||
@@ -371,7 +372,7 @@ def _convert_content( # noqa: C901
|
||||
)
|
||||
if (
|
||||
content.native.container is not None
|
||||
and content.native.container.expires_at > dt_util.utcnow()
|
||||
and content.native.container.expires_at > datetime.now(UTC)
|
||||
):
|
||||
container_id = content.native.container.id
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["anthropic==0.108.0"]
|
||||
"requirements": ["anthropic==0.96.0"]
|
||||
}
|
||||
|
||||
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
]
|
||||
self._model_list_cache[entry.entry_id] = model_list
|
||||
|
||||
family = (
|
||||
model.removeprefix("claude-")
|
||||
.removesuffix("-preview")
|
||||
.translate(str.maketrans("", "", "0123456789-."))
|
||||
or "haiku"
|
||||
)
|
||||
if "opus" in model:
|
||||
family = "claude-opus"
|
||||
elif "sonnet" in model:
|
||||
family = "claude-sonnet"
|
||||
else:
|
||||
family = "claude-haiku"
|
||||
|
||||
suggested_model = next(
|
||||
(
|
||||
model_option["value"]
|
||||
for model_option in sorted(
|
||||
(m for m in model_list if f"claude-{family}" in m["value"]),
|
||||
(m for m in model_list if family in m["value"]),
|
||||
key=lambda x: x["value"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ ATTR_EXTERNAL_URL = "external_url"
|
||||
ATTR_INTERNAL_URL = "internal_url"
|
||||
ATTR_LOCATION_NAME = "location_name"
|
||||
ATTR_INSTALLATION_TYPE = "installation_type"
|
||||
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
|
||||
ATTR_UUID = "uuid"
|
||||
ATTR_VERSION = "version"
|
||||
|
||||
@@ -221,7 +222,7 @@ class APIStatesView(HomeAssistantView):
|
||||
states = (
|
||||
state.as_dict_json
|
||||
for state in hass.states.async_all()
|
||||
if entity_perm(state.entity_id, POLICY_READ)
|
||||
if entity_perm(state.entity_id, "read")
|
||||
)
|
||||
response = web.Response(
|
||||
body=b"".join((b"[", b",".join(states), b"]")),
|
||||
@@ -293,10 +294,8 @@ class APIEntityStateView(HomeAssistantView):
|
||||
|
||||
# Read the state back for our response
|
||||
status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK
|
||||
if (state := hass.states.get(entity_id)) is None:
|
||||
return self.json_message(
|
||||
"Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
resp = self.json(state.as_dict(), status_code)
|
||||
|
||||
resp.headers.add("Location", f"/api/states/{entity_id}")
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["apprise"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["apprise==1.11.0"]
|
||||
"requirements": ["apprise==1.9.1"]
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""The Aqvify integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AqvifyConfigEntry, AqvifyCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
|
||||
"""Set up Aqvify from a config entry."""
|
||||
|
||||
coordinator = AqvifyCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
|
||||
"""Unload Aqvify config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Config flow for the Aqvify integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from pyaqvify import AqvifyAPI, AqvifyAuthException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Aqvify."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
hub = AqvifyAPI(
|
||||
user_input[CONF_API_KEY],
|
||||
websession=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
account_data = await hub.async_get_account_id()
|
||||
except AqvifyAuthException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientResponseError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(account_data.account_id)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Aqvify", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"aqvify_url": "https://app.aqvify.com/User",
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication confirmation."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
api_client = AqvifyAPI(
|
||||
user_input[CONF_API_KEY],
|
||||
websession=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
account_data = await api_client.async_get_account_id()
|
||||
except AqvifyAuthException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientResponseError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(account_data.account_id)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User initiated reconfiguration."""
|
||||
return await self.async_step_user()
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Constants for the Aqvify integration."""
|
||||
|
||||
DOMAIN = "aqvify"
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Coordinator for Aqvify integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from pyaqvify import AqvifyAPI, AqvifyAuthException, AqvifyDeviceData, AqvifyDevices
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
type AqvifyConfigEntry = ConfigEntry[AqvifyCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AqvifyCoordinatorData:
|
||||
"""Data class for storing coordinator data."""
|
||||
|
||||
devices: AqvifyDevices
|
||||
device_data: dict[str, AqvifyDeviceData]
|
||||
|
||||
|
||||
class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
|
||||
"""Data update coordinator for Aqvify devices."""
|
||||
|
||||
config_entry: AqvifyConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: AqvifyConfigEntry) -> None:
|
||||
"""Initialize the Aqvify data update coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
self.api_client = AqvifyAPI(
|
||||
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
try:
|
||||
await self.api_client.async_get_account_id()
|
||||
except AqvifyAuthException:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_api_key",
|
||||
) from None
|
||||
except ClientResponseError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"entry": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except TimeoutError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_timeout",
|
||||
translation_placeholders={
|
||||
"entry": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def _async_update_data(self) -> AqvifyCoordinatorData:
|
||||
"""Fetch device state."""
|
||||
try:
|
||||
devices = await self.api_client.async_get_devices()
|
||||
except AqvifyAuthException:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_api_key",
|
||||
) from None
|
||||
except ClientResponseError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"entry": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except TimeoutError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_timeout",
|
||||
translation_placeholders={
|
||||
"entry": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
|
||||
device_data = {}
|
||||
for device in devices.devices.values():
|
||||
try:
|
||||
device_key = str(device.device_key)
|
||||
device_data[
|
||||
device_key
|
||||
] = await self.api_client.async_get_device_latest_data(device_key)
|
||||
except AqvifyAuthException:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_api_key",
|
||||
) from None
|
||||
except ClientResponseError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"entry": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except TimeoutError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_timeout",
|
||||
translation_placeholders={
|
||||
"entry": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
|
||||
return AqvifyCoordinatorData(
|
||||
devices=devices,
|
||||
device_data=device_data,
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Diagnostics platform for Aqvify integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AqvifyConfigEntry
|
||||
|
||||
TO_REDACT = [CONF_API_KEY]
|
||||
TO_REDACT_AQVIFY = ["name"]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AqvifyConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
device_list_raw_data = entry.runtime_data.data.devices.raw
|
||||
device_data_raw_data = {
|
||||
key: device.raw_data
|
||||
for key, device in entry.runtime_data.data.device_data.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"devices": async_redact_data(device_list_raw_data, TO_REDACT_AQVIFY),
|
||||
"device_data": device_data_raw_data,
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Defines a base Aqvify entity."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AqvifyCoordinator
|
||||
|
||||
|
||||
class AqvifyBaseEntity(CoordinatorEntity[AqvifyCoordinator]):
|
||||
"""Defines a base Aqvify entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AqvifyCoordinator,
|
||||
description: EntityDescription,
|
||||
device_key: str,
|
||||
) -> None:
|
||||
"""Initialize the Aqvify entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
account_id = self.coordinator.config_entry.unique_id
|
||||
self.device_key = device_key
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{account_id}_{device_key}")},
|
||||
name=coordinator.data.devices.devices[device_key].name,
|
||||
manufacturer="Aqvify",
|
||||
configuration_url="https://app.aqvify.com",
|
||||
serial_number=device_key,
|
||||
)
|
||||
self._attr_unique_id = f"{account_id}_{device_key}_{description.key}"
|
||||
self.entity_description = description
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"meter_value": {
|
||||
"default": "mdi:waves-arrow-up"
|
||||
},
|
||||
"water_level": {
|
||||
"default": "mdi:waves"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "aqvify",
|
||||
"name": "Aqvify",
|
||||
"codeowners": ["@astrandb"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/aqvify",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyaqvify"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyaqvify==0.0.9"]
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No actions in this integration.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration do not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Sensor platform for Aqvify integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from pyaqvify import AqvifyDeviceData
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import UnitOfLength
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AqvifyConfigEntry
|
||||
from .entity import AqvifyBaseEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AqvifySensorEntityDescription(SensorEntityDescription):
|
||||
"""Description of an Aqvify sensor entity."""
|
||||
|
||||
value_fn: Callable[[AqvifyDeviceData], float | int | None]
|
||||
|
||||
|
||||
ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
|
||||
AqvifySensorEntityDescription(
|
||||
key="meter_value",
|
||||
translation_key="meter_value",
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda value: value.meter_value,
|
||||
),
|
||||
AqvifySensorEntityDescription(
|
||||
key="water_level",
|
||||
translation_key="water_level",
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda value: value.water_level,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AqvifyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aqvify sensor entities from a config entry."""
|
||||
async_add_entities(
|
||||
AqvifySensor(entry.runtime_data, description, device_key)
|
||||
for description in ENTITIES
|
||||
for device_key in entry.runtime_data.data.devices.devices
|
||||
)
|
||||
|
||||
|
||||
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
|
||||
"""Representation of an Aqvify sensor entity."""
|
||||
|
||||
entity_description: AqvifySensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data.device_data[self.device_key]
|
||||
)
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The entered API key corresponds to a different account."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::aqvify::config::step::user::data_description::api_key%]"
|
||||
},
|
||||
"description": "Reauthentication required. Please enter your updated API key."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "Your Aqvify API key"
|
||||
},
|
||||
"description": "Navigate to your [Aqvify account]({aqvify_url}), copy your API key, and paste it below."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"meter_value": {
|
||||
"name": "Meter value"
|
||||
},
|
||||
"water_level": {
|
||||
"name": "Water level"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "An error occurred while communicating with the Aqvify API for {entry}"
|
||||
},
|
||||
"api_timeout": {
|
||||
"message": "Timeout occurred while communicating with the Aqvify API for {entry}"
|
||||
},
|
||||
"invalid_api_key": {
|
||||
"message": "Invalid API key. Please verify your API key and try to reauthenticate."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -144,9 +144,7 @@ def _sensor_device_info_to_hass(
|
||||
adv: Aranet4Advertisement,
|
||||
) -> DeviceInfo:
|
||||
"""Convert a sensor device info to hass device info."""
|
||||
hass_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, adv.device.address)}
|
||||
)
|
||||
hass_device_info = DeviceInfo({})
|
||||
if adv.readings and adv.readings.name:
|
||||
hass_device_info[ATTR_NAME] = adv.readings.name
|
||||
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"codeowners": [],
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/arwn",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["arwn-client==0.2.1"]
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,113 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arwn_client import parse_message
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "arwn"
|
||||
|
||||
DATA_ARWN = "arwn"
|
||||
TOPIC = "arwn/#"
|
||||
|
||||
|
||||
def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None:
|
||||
"""Given a topic, dynamically create the right sensor type.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
parts = topic.split("/")
|
||||
unit = payload.get("units", "")
|
||||
domain = parts[1]
|
||||
if domain == "temperature":
|
||||
name = parts[2]
|
||||
if unit == "F":
|
||||
unit = UnitOfTemperature.FAHRENHEIT
|
||||
else:
|
||||
unit = UnitOfTemperature.CELSIUS
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE
|
||||
)
|
||||
]
|
||||
if domain == "moisture":
|
||||
name = f"{parts[2]} Moisture"
|
||||
return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")]
|
||||
if domain == "rain":
|
||||
if len(parts) >= 3 and parts[2] == "today":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic,
|
||||
"Rain Since Midnight",
|
||||
"since_midnight",
|
||||
UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
)
|
||||
]
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/total",
|
||||
"Total Rainfall",
|
||||
"total",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/rate",
|
||||
"Rainfall Rate",
|
||||
"rate",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
]
|
||||
if domain == "barometer":
|
||||
return [
|
||||
ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines")
|
||||
]
|
||||
if domain == "wind":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/speed",
|
||||
"Wind Speed",
|
||||
"speed",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/gust",
|
||||
"Wind Gust",
|
||||
"gust",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/dir",
|
||||
"Wind Direction",
|
||||
"direction",
|
||||
DEGREE,
|
||||
"mdi:compass",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
]
|
||||
return None
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return f"sensor.arwn_{slugify(name)}"
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -31,25 +118,28 @@ async def async_setup_platform(
|
||||
) -> None:
|
||||
"""Set up the ARWN platform."""
|
||||
|
||||
# Make sure MQTT integration is enabled and the client is available
|
||||
if not await mqtt.async_wait_for_mqtt_client(hass):
|
||||
_LOGGER.error("MQTT integration is not available")
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
|
||||
"""Process MQTT events as sensors."""
|
||||
try:
|
||||
event = json_loads_object(msg.payload)
|
||||
device = parse_message(msg.topic, event)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Failed to parse ARWN message on topic %s",
|
||||
msg.topic,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
"""Process events as sensors.
|
||||
|
||||
if device is None:
|
||||
When a new event on our topic (arwn/#) is received we map it
|
||||
into a known kind of sensor based on topic name. If we've
|
||||
never seen this before, we keep this sensor around in a global
|
||||
cache. If we have seen it before, we update the values of the
|
||||
existing sensor. Either way, we push an ha state update at the
|
||||
end for the new event we've seen.
|
||||
|
||||
This lets us dynamically incorporate sensors without any
|
||||
configuration on our side.
|
||||
"""
|
||||
event = json_loads_object(msg.payload)
|
||||
sensors = discover_sensors(msg.topic, event)
|
||||
if not sensors:
|
||||
return
|
||||
|
||||
if (store := hass.data.get(DATA_ARWN)) is None:
|
||||
@@ -58,71 +148,22 @@ async def async_setup_platform(
|
||||
if "timestamp" in event:
|
||||
del event["timestamp"]
|
||||
|
||||
new_sensors: list[ArwnSensor] = []
|
||||
for reading in device.readings:
|
||||
if not reading.expose:
|
||||
continue
|
||||
|
||||
unique_id = (
|
||||
f"{msg.topic}/{reading.sensor_key}"
|
||||
if len(device.readings) > 1
|
||||
else msg.topic
|
||||
)
|
||||
|
||||
try:
|
||||
device_class = (
|
||||
SensorDeviceClass(reading.device_class)
|
||||
if reading.device_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown device_class=%s for sensor %s",
|
||||
reading.device_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
device_class = None
|
||||
|
||||
try:
|
||||
state_class = (
|
||||
SensorStateClass(reading.state_class)
|
||||
if reading.state_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown state_class=%s for sensor %s",
|
||||
reading.state_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
state_class = None
|
||||
|
||||
if unique_id not in store:
|
||||
sensor = ArwnSensor(
|
||||
unique_id=unique_id,
|
||||
name=reading.sensor_name,
|
||||
state_key=reading.sensor_key,
|
||||
units=reading.unit,
|
||||
icon=reading.icon,
|
||||
device_class=device_class,
|
||||
state_class=state_class,
|
||||
event=event,
|
||||
)
|
||||
store[unique_id] = sensor
|
||||
for sensor in sensors:
|
||||
if sensor.name not in store:
|
||||
sensor.hass = hass
|
||||
sensor.set_event(event)
|
||||
store[sensor.name] = sensor
|
||||
_LOGGER.debug(
|
||||
"Registering sensor %(name)s => %(event)s",
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
{"name": sensor.name, "event": event},
|
||||
)
|
||||
new_sensors.append(sensor)
|
||||
async_add_entities((sensor,), True)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Recording sensor %(name)s => %(event)s",
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
{"name": sensor.name, "event": event},
|
||||
)
|
||||
store[unique_id].set_event(event)
|
||||
|
||||
if new_sensors:
|
||||
async_add_entities(new_sensors, True)
|
||||
store[sensor.name].set_event(event)
|
||||
|
||||
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
|
||||
|
||||
@@ -134,29 +175,29 @@ class ArwnSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
topic: str,
|
||||
name: str,
|
||||
state_key: str,
|
||||
units: str,
|
||||
icon: str | None = None,
|
||||
device_class: SensorDeviceClass | None = None,
|
||||
state_class: SensorStateClass | None = None,
|
||||
event: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_id = _slug(name)
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
# This mqtt topic for the sensor which is its uid
|
||||
self._attr_unique_id = topic
|
||||
self._state_key = state_key
|
||||
self._attr_native_unit_of_measurement = units
|
||||
self._attr_icon = icon
|
||||
self._attr_device_class = device_class
|
||||
self._attr_state_class = state_class
|
||||
if event is not None:
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(state_key)
|
||||
|
||||
def set_event(self, event: dict[str, Any]) -> None:
|
||||
"""Update the sensor with the most recent event."""
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(self._state_key)
|
||||
ev: dict[str, Any] = {}
|
||||
ev.update(event)
|
||||
self._attr_extra_state_attributes = ev
|
||||
self._attr_native_value = ev.get(self._state_key)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hassil.parse_expression import parse_sentence
|
||||
from hassil.parser import ParseError
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
PUNCTUATION_END_WORD,
|
||||
@@ -167,7 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
is_valid_sentence,
|
||||
),
|
||||
}
|
||||
],
|
||||
@@ -205,8 +201,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
for sentence in value:
|
||||
# Exclude {list_references} which may contain punctuation characters.
|
||||
sentence = _remove_list_references(sentence)
|
||||
if (
|
||||
PUNCTUATION_START.search(sentence)
|
||||
or PUNCTUATION_END.search(sentence)
|
||||
@@ -218,21 +212,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
return value
|
||||
|
||||
|
||||
def _remove_list_references(sentence: str) -> str:
|
||||
"""Remove {list_references} from a sentence for linting."""
|
||||
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
|
||||
|
||||
|
||||
def is_valid_sentence(value: list[str]) -> list[str]:
|
||||
"""Validate result can be parsed by hassil."""
|
||||
for sentence in value:
|
||||
try:
|
||||
parse_sentence(sentence)
|
||||
except ParseError as err:
|
||||
raise vol.Invalid(f"invalid sentence: {err}") from err
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.7.0"]
|
||||
"requirements": ["hassil==3.5.0"]
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Virtual integration: Avosdim."""
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "avosdim",
|
||||
"name": "Avosdim",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
}
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["mozart-api==6.2.0.44.0"],
|
||||
"requirements": ["mozart-api==5.3.1.108.2"],
|
||||
"zeroconf": ["_bangolufsen._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.session import ApiHost
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -17,9 +18,10 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DEFAULT_SETUP_TIMEOUT
|
||||
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
|
||||
from .helpers import get_maybe_authenticated_session
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[Box]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -33,6 +35,8 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bool:
|
||||
"""Set up BleBox devices from a config entry."""
|
||||
@@ -54,10 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
|
||||
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
coordinator = BleBoxCoordinator(hass, entry, product)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
entry.runtime_data = product
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -11,23 +11,13 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
BINARY_SENSOR_TYPES = (
|
||||
BinarySensorEntityDescription(
|
||||
key="moisture",
|
||||
device_class=BinarySensorDeviceClass.MOISTURE,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="open",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="input",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -37,27 +27,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxBinarySensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("binary_sensors", [])
|
||||
BleBoxBinarySensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("binary_sensors", [])
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity):
|
||||
"""Representation of a BleBox binary sensor feature."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: BinarySensorFeature,
|
||||
description: BinarySensorEntityDescription,
|
||||
self, feature: BinarySensorFeature, description: BinarySensorEntityDescription
|
||||
) -> None:
|
||||
"""Initialize a BleBox binary sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,26 +2,12 @@
|
||||
|
||||
import blebox_uniapi.button
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
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
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BUTTON_TYPES: dict[str, ButtonEntityDescription] = {
|
||||
"up": ButtonEntityDescription(key="up", translation_key="up"),
|
||||
"down": ButtonEntityDescription(key="down", translation_key="down"),
|
||||
"fav": ButtonEntityDescription(key="fav", translation_key="fav"),
|
||||
"open": ButtonEntityDescription(key="open", translation_key="open"),
|
||||
"close": ButtonEntityDescription(key="close", translation_key="close"),
|
||||
}
|
||||
|
||||
_DEFAULT_BUTTON = ButtonEntityDescription(key="button")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -30,33 +16,35 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox button entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxButtonEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("buttons", [])
|
||||
BleBoxButtonEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("buttons", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
|
||||
"""Representation of BleBox buttons."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.button.Button) -> None:
|
||||
"""Initialize a BleBox button feature."""
|
||||
super().__init__(feature)
|
||||
self._attr_icon = self.get_icon()
|
||||
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = self._get_description()
|
||||
def get_icon(self) -> str | None:
|
||||
"""Return icon for endpoint."""
|
||||
if "up" in self._feature.query_string:
|
||||
return "mdi:arrow-up-circle"
|
||||
if "down" in self._feature.query_string:
|
||||
return "mdi:arrow-down-circle"
|
||||
if "fav" in self._feature.query_string:
|
||||
return "mdi:heart-circle"
|
||||
if "open" in self._feature.query_string:
|
||||
return "mdi:arrow-up-circle"
|
||||
if "close" in self._feature.query_string:
|
||||
return "mdi:arrow-down-circle"
|
||||
return None
|
||||
|
||||
def _get_description(self) -> ButtonEntityDescription:
|
||||
"""Return the description matching this button's query string."""
|
||||
for key, description in BUTTON_TYPES.items():
|
||||
if key in self._feature.query_string:
|
||||
return description
|
||||
return _DEFAULT_BUTTON
|
||||
|
||||
@blebox_command
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._feature.set()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox climate entity."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.climate
|
||||
@@ -16,9 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
BLEBOX_TO_HVACMODE = {
|
||||
0: HVACMode.OFF,
|
||||
@@ -40,12 +40,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox climate entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxClimateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("climates", [])
|
||||
BleBoxClimateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("climates", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
|
||||
@@ -109,7 +108,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
"""Return the desired thermostat temperature."""
|
||||
return self._feature.desired
|
||||
|
||||
@blebox_command
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the climate entity mode."""
|
||||
if hvac_mode in [HVACMode.HEAT, HVACMode.COOL]:
|
||||
@@ -118,7 +116,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
|
||||
await self._feature.async_off()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the thermostat temperature."""
|
||||
value = kwargs[ATTR_TEMPERATURE]
|
||||
|
||||
@@ -33,14 +33,23 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
STEP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
def create_schema(previous_input=None):
|
||||
"""Create a schema with given values as default."""
|
||||
if previous_input is not None:
|
||||
host = previous_input[CONF_HOST]
|
||||
port = previous_input[CONF_PORT]
|
||||
else:
|
||||
host = DEFAULT_HOST
|
||||
port = DEFAULT_PORT
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=host): str,
|
||||
vol.Required(CONF_PORT, default=port): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LOG_MSG = {
|
||||
@@ -60,44 +69,18 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.device_config: dict[str, Any] = {}
|
||||
|
||||
def handle_step_exception(
|
||||
self, exception, schema, host, port, message_id, log_fn, step_id
|
||||
self, step, exception, schema, host, port, message_id, log_fn
|
||||
):
|
||||
"""Handle step exceptions."""
|
||||
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
errors={"base": message_id},
|
||||
description_placeholders={"address": f"{host}:{port}"},
|
||||
)
|
||||
|
||||
async def _async_from_host_or_form(
|
||||
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
|
||||
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
|
||||
"""Try to connect to the device; return product or an error form."""
|
||||
schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input)
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
try:
|
||||
return await Box.async_from_host(api_host), None
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
|
||||
)
|
||||
except Error as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id
|
||||
)
|
||||
except RuntimeError as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -162,11 +145,12 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle initial user-triggered config step."""
|
||||
hass = self.hass
|
||||
schema = create_schema(user_input)
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_SCHEMA,
|
||||
data_schema=schema,
|
||||
errors={},
|
||||
description_placeholders={},
|
||||
)
|
||||
@@ -189,60 +173,36 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
|
||||
)
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="user"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
try:
|
||||
product = await Box.async_from_host(api_host)
|
||||
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return self.handle_step_exception(
|
||||
"user",
|
||||
ex,
|
||||
schema,
|
||||
host,
|
||||
port,
|
||||
UNSUPPORTED_VERSION,
|
||||
_LOGGER.debug,
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error
|
||||
)
|
||||
|
||||
except Error as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning
|
||||
)
|
||||
|
||||
except RuntimeError as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, UNKNOWN, _LOGGER.error
|
||||
)
|
||||
|
||||
# Check if configured but IP changed since
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=product.name, data=user_input)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of a BleBox device."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_SCHEMA, reconfigure_entry.data
|
||||
),
|
||||
)
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
websession = get_maybe_authenticated_session(self.hass, password, username)
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
|
||||
)
|
||||
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="reconfigure"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
|
||||
if username is not None:
|
||||
data_updates[CONF_USERNAME] = username
|
||||
if password is not None:
|
||||
data_updates[CONF_PASSWORD] = password
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates=data_updates,
|
||||
)
|
||||
|
||||
@@ -14,23 +14,6 @@ UNKNOWN = "unknown"
|
||||
DEFAULT_HOST = "192.168.0.2"
|
||||
DEFAULT_PORT = 80
|
||||
|
||||
OPEN_STATUS: dict[int, str] = {
|
||||
0: "open",
|
||||
1: "unclosed_or_unlocked",
|
||||
2: "ajar",
|
||||
3: "closed_but_unlocked",
|
||||
4: "closed",
|
||||
}
|
||||
|
||||
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
|
||||
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
|
||||
|
||||
CO2_LEVEL: dict[int, str] = {
|
||||
0: "excellent",
|
||||
1: "good",
|
||||
2: "acceptable",
|
||||
3: "medium",
|
||||
4: "poor",
|
||||
5: "unhealthy",
|
||||
6: "hazardous",
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""DataUpdateCoordinator for BleBox devices."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[BleBoxCoordinator]
|
||||
|
||||
|
||||
class BleBoxCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator for a single BleBox device."""
|
||||
|
||||
config_entry: BleBoxConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: BleBoxConfigEntry, box: Box
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=5),
|
||||
)
|
||||
self.box = box
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from the BleBox device."""
|
||||
try:
|
||||
await self.box.async_update_data()
|
||||
except Error as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="data_update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
@@ -17,11 +17,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
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BLEBOX_TO_COVER_DEVICE_CLASSES = {
|
||||
"gate": CoverDeviceClass.GATE,
|
||||
@@ -63,22 +59,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxCoverEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("covers", [])
|
||||
BleBoxCoverEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("covers", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Representation of a BleBox cover feature."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||
"""Initialize a BleBox cover feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
@@ -90,10 +83,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
|
||||
if feature.has_tilt:
|
||||
self._attr_supported_features |= (
|
||||
CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
|
||||
CoverEntityFeature.SET_TILT_POSITION
|
||||
| CoverEntityFeature.OPEN_TILT
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
)
|
||||
if feature.is_calibrated:
|
||||
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
|
||||
|
||||
if feature.tilt_only:
|
||||
self._attr_supported_features &= ~(
|
||||
@@ -142,40 +135,33 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Return whether cover is closed."""
|
||||
return self._is_state(CoverState.CLOSED)
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover position."""
|
||||
await self._feature.async_open()
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover position."""
|
||||
await self._feature.async_close()
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover tilt."""
|
||||
position = 50 if self._feature.is_tilt_180 else 0
|
||||
await self._feature.async_set_tilt_position(position)
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover tilt."""
|
||||
# note: values are reversed
|
||||
await self._feature.async_set_tilt_position(100)
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Set the cover position."""
|
||||
position = kwargs[ATTR_POSITION]
|
||||
await self._feature.async_set_position(100 - position)
|
||||
|
||||
@blebox_command
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self._feature.async_stop()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Set the tilt position."""
|
||||
position = kwargs[ATTR_TILT_POSITION]
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Diagnostics support for BleBox devices."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: BleBoxConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
product = entry.runtime_data.box
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"device": {
|
||||
"name": product.name,
|
||||
"type": product.type,
|
||||
"model": product.model,
|
||||
"unique_id": product.unique_id,
|
||||
"firmware_version": product.firmware_version,
|
||||
"hardware_version": product.hardware_version,
|
||||
"available_firmware_version": product.available_firmware_version,
|
||||
"api_version": product.api_version,
|
||||
"last_data": product.last_data,
|
||||
},
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
"""Base entity for the BleBox devices integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.feature import Feature
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BleBoxCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
"""Implements a common class for entities representing a BleBox feature."""
|
||||
|
||||
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
|
||||
def __init__(self, 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
|
||||
@@ -27,3 +30,10 @@ class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
sw_version=product.firmware_version,
|
||||
configuration_url=f"http://{product.address}",
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity state."""
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
_LOGGER.error("Updating '%s' failed: %s", self.name, ex)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"close": {
|
||||
"default": "mdi:arrow-down-circle"
|
||||
},
|
||||
"down": {
|
||||
"default": "mdi:arrow-down-circle"
|
||||
},
|
||||
"fav": {
|
||||
"default": "mdi:heart-circle"
|
||||
},
|
||||
"open": {
|
||||
"default": "mdi:arrow-up-circle"
|
||||
},
|
||||
"up": {
|
||||
"default": "mdi:arrow-up-circle"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"co2_level": {
|
||||
"default": "mdi:molecule-co2"
|
||||
},
|
||||
"open_status": {
|
||||
"default": "mdi:window-open"
|
||||
},
|
||||
"power_consumption": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox light entities implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -19,18 +20,15 @@ from homeassistant.components.light import (
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import DOMAIN, LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -39,12 +37,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxLightEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("lights", [])
|
||||
BleBoxLightEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("lights", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
COLOR_MODE_MAP = {
|
||||
@@ -64,11 +61,9 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
_attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS
|
||||
_attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
|
||||
"""Initialize a BleBox light."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
if feature.effect_list:
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
@@ -170,7 +165,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
return None
|
||||
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex))
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
|
||||
@@ -216,10 +210,8 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
try:
|
||||
await self._feature.async_on(value)
|
||||
except ValueError as exc:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="bad_value",
|
||||
translation_placeholders={"error": str(exc)},
|
||||
raise ValueError(
|
||||
f"Turning on '{self.name}' failed: Bad value {value}"
|
||||
) from exc
|
||||
|
||||
if effect is not None:
|
||||
@@ -227,13 +219,11 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
effect_value = self.effect_list.index(effect)
|
||||
await self._feature.async_api_command("effect", effect_value)
|
||||
except ValueError as exc:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="effect_not_found",
|
||||
translation_placeholders={"error": str(exc)},
|
||||
raise ValueError(
|
||||
f"Turning on with effect '{self.name}' failed: {effect} not in"
|
||||
" effect list."
|
||||
) from exc
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self._feature.async_off()
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["blebox_uniapi"],
|
||||
"requirements": ["blebox-uniapi==2.5.5"],
|
||||
"requirements": ["blebox-uniapi==2.5.4"],
|
||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import blebox_uniapi.sensor
|
||||
|
||||
@@ -14,7 +12,6 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
UnitOfApparentPower,
|
||||
@@ -29,139 +26,94 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import CO2_LEVEL, OPEN_STATUS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BleBoxSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a BleBox sensor entity."""
|
||||
|
||||
value_fn: Callable[[StateType], StateType] = lambda v: v
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
BleBoxSensorEntityDescription(
|
||||
SENSOR_TYPES = (
|
||||
SensorEntityDescription(
|
||||
key="pm1",
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="pm2_5",
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="pm10",
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="powerConsumption",
|
||||
translation_key="power_consumption",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
icon="mdi:lightning-bolt",
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="wind",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="illuminance",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="forwardActiveEnergy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="reverseActiveEnergy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="reactivePower",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="activePower",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="apparentPower",
|
||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
SensorEntityDescription(
|
||||
key="frequency",
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="openStatus",
|
||||
translation_key="open_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(OPEN_STATUS.values()),
|
||||
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="co2Definition",
|
||||
translation_key="co2_level",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CO2_LEVEL.values()),
|
||||
value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -172,35 +124,31 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("sensors", [])
|
||||
BleBoxSensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("sensors", [])
|
||||
for description in SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
|
||||
"""Representation of a BleBox sensor feature."""
|
||||
|
||||
entity_description: BleBoxSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: blebox_uniapi.sensor.BaseSensor,
|
||||
description: BleBoxSensorEntityDescription,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a BleBox sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self):
|
||||
"""Return the state."""
|
||||
return self.entity_description.value_fn(self._feature.native_value)
|
||||
return self._feature.native_value
|
||||
|
||||
@property
|
||||
def last_reset(self) -> datetime | None:
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"address_already_configured": "A BleBox device is already configured at {address}.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -13,16 +11,6 @@
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Update the connection settings for your BleBox device.",
|
||||
"title": "Reconfigure BleBox device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
@@ -34,50 +22,5 @@
|
||||
"title": "Set up your BleBox device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"co2_level": {
|
||||
"name": "Carbon dioxide level",
|
||||
"state": {
|
||||
"acceptable": "Acceptable",
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"hazardous": "Hazardous",
|
||||
"medium": "Medium",
|
||||
"poor": "Poor",
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
},
|
||||
"open_status": {
|
||||
"state": {
|
||||
"ajar": "Ajar",
|
||||
"closed": "[%key:common::state::closed%]",
|
||||
"closed_but_unlocked": "Closed but unlocked",
|
||||
"open": "[%key:common::state::open%]",
|
||||
"unclosed_or_unlocked": "Unclosed or unlocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"bad_value": {
|
||||
"message": "Turning on the light failed: {error}"
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Failed to execute command on the BleBox device: {error}"
|
||||
},
|
||||
"data_update_failed": {
|
||||
"message": "An error occurred while communicating with the BleBox device: {error}"
|
||||
},
|
||||
"effect_not_found": {
|
||||
"message": "The specified light effect is not available on this device: {error}"
|
||||
},
|
||||
"install_failed": {
|
||||
"message": "Failed to install firmware update on the BleBox device: {error}"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Failed to fetch firmware update information from the BleBox device: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox switch implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.switch
|
||||
@@ -10,9 +11,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,12 +21,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox switch entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSwitchEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("switches", [])
|
||||
BleBoxSwitchEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("switches", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity):
|
||||
@@ -39,12 +38,10 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
|
||||
"""Return whether switch is on."""
|
||||
return self._feature.is_on
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self._feature.async_turn_on()
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self._feature.async_turn_off()
|
||||
|
||||
@@ -18,11 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
|
||||
|
||||
@@ -36,12 +33,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox update entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxUpdateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("updates", [])
|
||||
BleBoxUpdateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
||||
]
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
||||
@@ -52,16 +48,9 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return True because firmware versions cannot be fetched via coordinator."""
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.update.Update
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self._in_progress_old_version: str | None = None
|
||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
||||
self._poll_attempts: int = 0
|
||||
@@ -87,11 +76,7 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
raise HomeAssistantError(ex) from ex
|
||||
self._sync_sw_version()
|
||||
|
||||
@property
|
||||
@@ -126,11 +111,7 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
await self._feature.async_install()
|
||||
except Error as ex:
|
||||
self._reset_progress()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="install_failed",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
raise HomeAssistantError(ex) from ex
|
||||
self._poll_cancel = async_call_later(
|
||||
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
|
||||
)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Utilities for BleBox."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
|
||||
def blebox_command[_BleBoxEntityT: BleBoxEntity, **_P, _R](
|
||||
func: Callable[Concatenate[_BleBoxEntityT, _P], Awaitable[_R]],
|
||||
) -> Callable[Concatenate[_BleBoxEntityT, _P], Coroutine[Any, Any, _R]]:
|
||||
"""Decorate BleBox calls that send commands to the device.
|
||||
|
||||
Catches BleBox errors and refreshes the coordinator after the command.
|
||||
"""
|
||||
|
||||
async def handler(self: _BleBoxEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except Error as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
return handler
|
||||
@@ -169,7 +169,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
|
||||
try:
|
||||
await self._camera.save_recent_clips(output_dir=file_path)
|
||||
except OSError as err:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
str(err),
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cant_write",
|
||||
) from err
|
||||
@@ -189,7 +191,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
|
||||
try:
|
||||
await self._camera.video_to_file(filename)
|
||||
except OSError as err:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
str(err),
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cant_write",
|
||||
) from err
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"cant_write": {
|
||||
"message": "Can't write to file, check logs for details."
|
||||
"message": "Can't write to file."
|
||||
},
|
||||
"failed_arm": {
|
||||
"message": "Blink failed to arm camera."
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.8.3"
|
||||
"habluetooth==6.8.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""The Brands integration."""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Container, Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, override
|
||||
|
||||
from aiohttp import ClientError, hdrs, web
|
||||
from aiohttp import ClientError, web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback, valid_domain
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
|
||||
class _BrandsBaseView(HomeAssistantView):
|
||||
"""Base view for serving brand images."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the view."""
|
||||
self._hass = hass
|
||||
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
|
||||
|
||||
def _authenticate(self, request: web.Request) -> None:
|
||||
"""Authenticate the request using Bearer token or query token."""
|
||||
access_tokens: deque[str] = self._hass.data[DOMAIN]
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
raise web.HTTPForbidden
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
return self._hass.data[DOMAIN]
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
self,
|
||||
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for an integration brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for a hardware brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not CATEGORY_RE.match(category):
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
# Hardware images have dynamic names like "manufacturer_model.png"
|
||||
|
||||
@@ -230,6 +230,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
entry.minor_version,
|
||||
)
|
||||
|
||||
if entry.version > 1:
|
||||
# Downgraded from a future version; cannot migrate.
|
||||
return False
|
||||
|
||||
# 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available
|
||||
# heating circuits from the device; fall back to [1] (pre-multi-circuit
|
||||
# default) if the device is unreachable or the endpoint is unsupported.
|
||||
|
||||
@@ -183,6 +183,7 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
|
||||
try:
|
||||
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
|
||||
except BSBLANError as err:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_data_error",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -12,16 +12,16 @@ import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final, final
|
||||
from typing import Any, Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import attr
|
||||
from propcache.api import cached_property, under_cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
class CameraView(HomeAssistantView):
|
||||
"""Base CameraView."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, component: EntityComponent[Camera]) -> None:
|
||||
"""Initialize a basic camera view."""
|
||||
self.component = component
|
||||
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return camera.access_tokens
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
if (camera := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in camera.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or camera access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if not camera.is_on:
|
||||
_LOGGER.debug("Camera is off")
|
||||
raise web.HTTPServiceUnavailable
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["dio_chacon_api"],
|
||||
"requirements": ["dio-chacon-wifi-api==1.3.0"]
|
||||
"requirements": ["dio-chacon-wifi-api==1.2.2"]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ from homeassistant.components.alexa import (
|
||||
entities as alexa_entities,
|
||||
errors as alexa_errors,
|
||||
)
|
||||
from homeassistant.components.frontend import DATA_THEMES
|
||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
@@ -509,15 +508,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"custom_integrations": custom_integrations,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _get_themes_info(self, hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Collect information about user-installed custom themes."""
|
||||
themes: dict[str, Any] = hass.data.get(DATA_THEMES, {})
|
||||
return {
|
||||
"count": len(themes),
|
||||
"themes": sorted(themes),
|
||||
}
|
||||
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -579,25 +569,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
)
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Add custom themes information
|
||||
try:
|
||||
themes_info = self._get_themes_info(hass)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package generation
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += "Unable to collect themes information\n\n"
|
||||
else:
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += f"Custom themes: {themes_info['count']}\n\n"
|
||||
|
||||
if themes_info["themes"]:
|
||||
markdown += "<details><summary>Custom themes</summary>\n\n"
|
||||
markdown += "Name\n"
|
||||
markdown += "---\n"
|
||||
for theme in themes_info["themes"]:
|
||||
markdown += f"{theme}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
||||
@@ -87,6 +87,10 @@ async def async_migrate_entry(
|
||||
) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
if config_entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Offer sentence based automation rules."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hassil.parse_expression import parse_sentence
|
||||
from hassil.parser import ParseError
|
||||
from hassil.recognize import RecognizeResult
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
@@ -34,8 +31,6 @@ TRIGGER_CALLBACK_TYPE = Callable[
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
for sentence in value:
|
||||
# Exclude {list_references} which may contain punctuation characters.
|
||||
sentence = _remove_list_references(sentence)
|
||||
if (
|
||||
PUNCTUATION_START.search(sentence)
|
||||
or PUNCTUATION_END.search(sentence)
|
||||
@@ -47,21 +42,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
return value
|
||||
|
||||
|
||||
def _remove_list_references(sentence: str) -> str:
|
||||
"""Remove {list_references} from a sentence for linting."""
|
||||
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
|
||||
|
||||
|
||||
def is_valid_sentence(value: list[str]) -> list[str]:
|
||||
"""Validate result can be parsed by hassil."""
|
||||
for sentence in value:
|
||||
try:
|
||||
parse_sentence(sentence)
|
||||
except ParseError as err:
|
||||
raise vol.Invalid(f"invalid sentence: {err}") from err
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
@@ -78,11 +58,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): DOMAIN,
|
||||
vol.Required(CONF_COMMAND): vol.All(
|
||||
cv.ensure_list,
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
is_valid_sentence,
|
||||
cv.ensure_list, [cv.string], has_one_non_empty_item, has_no_punctuation
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id
|
||||
from data_grand_lyon_ha import DataGrandLyonClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -18,12 +18,6 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_LINE,
|
||||
@@ -49,6 +43,13 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_STOP_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LINE): str,
|
||||
vol.Required(CONF_STOP_ID): vol.Coerce(int),
|
||||
}
|
||||
)
|
||||
|
||||
STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STATION_ID): vol.Coerce(int),
|
||||
@@ -178,126 +179,33 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
class StopSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle a subentry flow for adding a Data Grand Lyon stop."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
self._stops: list[TclStop] = []
|
||||
self._selected_stop: TclStop | None = None
|
||||
self._selected_stop_id: int | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Pick a stop from the list fetched from the API, or enter one manually."""
|
||||
if not self._stops:
|
||||
if error := await self._async_load_stops():
|
||||
return self.async_abort(reason=error)
|
||||
"""Handle the user step to add a new stop."""
|
||||
entry = self._get_entry()
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
stop_id = int(user_input[CONF_STOP_ID])
|
||||
except ValueError:
|
||||
errors[CONF_STOP_ID] = "invalid_stop_id"
|
||||
else:
|
||||
self._selected_stop_id = stop_id
|
||||
self._selected_stop = find_tcl_stop_by_id(self._stops, stop_id)
|
||||
return await self.async_step_pick_line()
|
||||
line = user_input[CONF_LINE]
|
||||
stop_id = user_input[CONF_STOP_ID]
|
||||
unique_id = f"{line}_{stop_id}"
|
||||
|
||||
options = [
|
||||
SelectOptionDict(value=str(stop.id), label=_stop_label(stop))
|
||||
for stop in sorted(
|
||||
self._stops, key=lambda s: (s.nom, s.commune or "", s.id or 0)
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
name = f"{line} - Stop {stop_id}"
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
|
||||
unique_id=unique_id,
|
||||
)
|
||||
]
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STOP_ID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
sort=False,
|
||||
custom_value=True,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
data_schema=STEP_STOP_DATA_SCHEMA,
|
||||
)
|
||||
|
||||
async def async_step_pick_line(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Pick a line from the selected stop's desserte, or enter one manually."""
|
||||
assert self._selected_stop_id is not None
|
||||
if user_input is not None:
|
||||
return self._create_stop(
|
||||
line=user_input[CONF_LINE], stop_id=self._selected_stop_id
|
||||
)
|
||||
|
||||
options = self._selected_stop.desserte if self._selected_stop else []
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LINE): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
custom_value=True,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="pick_line", data_schema=schema)
|
||||
|
||||
async def _async_load_stops(self) -> str | None:
|
||||
"""Fetch TCL stops from the API, returning an error key on failure."""
|
||||
entry = self._get_entry()
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = DataGrandLyonClient(
|
||||
session=session,
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
self._stops = await client.get_tcl_stops()
|
||||
except ClientResponseError as err:
|
||||
if err.status in (401, 403):
|
||||
return "invalid_auth"
|
||||
return "cannot_connect"
|
||||
except ClientError, TimeoutError:
|
||||
return "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error fetching Data Grand Lyon TCL stops")
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
def _create_stop(self, line: str, stop_id: int) -> SubentryFlowResult:
|
||||
"""Create the stop subentry, aborting on duplicate."""
|
||||
entry = self._get_entry()
|
||||
unique_id = f"{line}_{stop_id}"
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{line} - Stop {stop_id}",
|
||||
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
|
||||
def _stop_label(stop: TclStop) -> str:
|
||||
label = stop.nom
|
||||
# variable extracted to please codespell.
|
||||
address = stop.adresse # codespell:ignore adresse
|
||||
if address or stop.commune:
|
||||
label += " (" + ", ".join(filter(None, [address, stop.commune])) + ")"
|
||||
label += f" - {stop.id}"
|
||||
|
||||
return label
|
||||
|
||||
|
||||
class VelovStationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle a subentry flow for adding a Vélo'v station."""
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["data-grand-lyon-ha==0.8.0"]
|
||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -46,30 +46,17 @@
|
||||
"config_subentries": {
|
||||
"stop": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"entry_type": "Transit stop",
|
||||
"error": {
|
||||
"invalid_stop_id": "Stop ID must be a number."
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add transit stop"
|
||||
},
|
||||
"step": {
|
||||
"pick_line": {
|
||||
"data": {
|
||||
"line": "Line"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"stop_id": "Stop"
|
||||
},
|
||||
"data_description": {
|
||||
"stop_id": "Search by stop name, address or city, or enter a stop ID directly."
|
||||
"line": "Line",
|
||||
"stop_id": "Stop ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -28,19 +27,7 @@ async def async_setup_entry(
|
||||
BinarySensorDeviceClass.MOISTURE,
|
||||
),
|
||||
DemoBinarySensor(
|
||||
"binary_2",
|
||||
"Movement Backyard",
|
||||
True,
|
||||
BinarySensorDeviceClass.MOTION,
|
||||
),
|
||||
DemoBinarySensor(
|
||||
"binary_3",
|
||||
"Outside Temperature",
|
||||
False,
|
||||
BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
device_id="sensor_1",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_name="Battery Charging",
|
||||
"binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -59,9 +46,6 @@ class DemoBinarySensor(BinarySensorEntity):
|
||||
device_name: str,
|
||||
state: bool,
|
||||
device_class: BinarySensorDeviceClass,
|
||||
device_id: str | None = None,
|
||||
entity_category: EntityCategory | None = None,
|
||||
entity_name: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the demo sensor."""
|
||||
self._unique_id = unique_id
|
||||
@@ -70,12 +54,10 @@ class DemoBinarySensor(BinarySensorEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
# Serial numbers are unique identifiers within a specific domain
|
||||
(DOMAIN, device_id or unique_id)
|
||||
(DOMAIN, self.unique_id)
|
||||
},
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_entity_category = entity_category
|
||||
self._attr_name = entity_name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
|
||||
@@ -54,6 +54,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
if config_entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1:
|
||||
if config_entry.minor_version < 2:
|
||||
new_options = {**config_entry.options}
|
||||
|
||||
@@ -22,7 +22,6 @@ from .const import ( # noqa: F401
|
||||
ATTR_LOCATION_NAME,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
ATTR_TRACKING_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_NEW_DEVICE_DEFAULTS,
|
||||
@@ -37,7 +36,6 @@ from .const import ( # noqa: F401
|
||||
PLATFORM_TYPE_LEGACY,
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
TrackingType,
|
||||
)
|
||||
from .entity import ( # noqa: F401
|
||||
BaseScannerEntity,
|
||||
|
||||
@@ -25,18 +25,6 @@ class SourceType(StrEnum):
|
||||
BLUETOOTH_LE = "bluetooth_le"
|
||||
|
||||
|
||||
class TrackingType(StrEnum):
|
||||
"""Tracking type for device trackers.
|
||||
|
||||
Describes how the tracker determines presence: by the device's geographic
|
||||
position (e.g. GPS) or by its connection to a known endpoint (e.g. a router
|
||||
or beacon associated with a zone).
|
||||
"""
|
||||
|
||||
CONNECTION = "connection"
|
||||
POSITION = "position"
|
||||
|
||||
|
||||
CONF_SCAN_INTERVAL: Final = "interval_seconds"
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=12)
|
||||
|
||||
@@ -59,7 +47,6 @@ ATTR_IN_ZONES: Final = "in_zones"
|
||||
ATTR_LOCATION_NAME: Final = "location_name"
|
||||
ATTR_MAC: Final = "mac"
|
||||
ATTR_SOURCE_TYPE: Final = "source_type"
|
||||
ATTR_TRACKING_TYPE: Final = "tracking_type"
|
||||
ATTR_CONSIDER_HOME: Final = "consider_home"
|
||||
ATTR_IP: Final = "ip"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Provide functionality to keep track of devices."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
@@ -23,7 +22,6 @@ from homeassistant.core import (
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
@@ -39,7 +37,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.loader import async_suggest_report_issue
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -48,17 +45,13 @@ from .const import (
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
ATTR_TRACKING_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
SourceType,
|
||||
TrackingType,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
||||
|
||||
|
||||
@@ -171,35 +164,11 @@ class BaseTrackerEntity(Entity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_source_type: SourceType
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "battery_level" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated battery_level property on "
|
||||
"a subclass of BaseTrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device.
|
||||
|
||||
Percentage from 0-100.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
return None
|
||||
|
||||
@@ -240,44 +209,16 @@ class TrackerEntity(
|
||||
"""Base class for a tracked device."""
|
||||
|
||||
entity_description: TrackerEntityDescription
|
||||
_attr_capability_attributes: dict[str, Any] = {
|
||||
ATTR_TRACKING_TYPE: TrackingType.POSITION
|
||||
}
|
||||
_attr_in_zones: list[str] | None = None
|
||||
_attr_latitude: float | None = None
|
||||
_attr_location_accuracy: float = 0
|
||||
# _attr_location_name is deprecated and will be removed in Home Assistant 2027.7
|
||||
_attr_location_name: str | None = None
|
||||
_attr_longitude: float | None = None
|
||||
_attr_source_type: SourceType = SourceType.GPS
|
||||
|
||||
__active_zone: State | None = None
|
||||
# If we reported setting deprecated _attr_location_name
|
||||
__deprecated_attr_location_name_reported = False
|
||||
__in_zones: list[str] | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "location_name" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated location_name property on "
|
||||
"an instance of TrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling for entities that have location pushed."""
|
||||
@@ -308,32 +249,7 @@ class TrackerEntity(
|
||||
|
||||
@cached_property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
if (location_name := self._attr_location_name) is not None:
|
||||
if (
|
||||
not self.__deprecated_attr_location_name_reported
|
||||
and not self.__class__.__module__.startswith(
|
||||
"homeassistant.components."
|
||||
)
|
||||
):
|
||||
report_issue = async_suggest_report_issue(
|
||||
self.hass, module=self.__class__.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is setting the deprecated _attr_location_name attribute "
|
||||
"on an instance of TrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
self.__class__.__module__,
|
||||
self.__class__.__name__,
|
||||
report_issue,
|
||||
)
|
||||
self.__deprecated_attr_location_name_reported = True
|
||||
return location_name
|
||||
"""Return a location name for the current location of the device."""
|
||||
return self._attr_location_name
|
||||
|
||||
@cached_property
|
||||
@@ -416,9 +332,6 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
addresses being used to identify the device.
|
||||
"""
|
||||
|
||||
_attr_capability_attributes: dict[str, Any] = {
|
||||
ATTR_TRACKING_TYPE: TrackingType.CONNECTION
|
||||
}
|
||||
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
|
||||
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
|
||||
@@ -40,13 +40,6 @@
|
||||
"gps": "GPS",
|
||||
"router": "Router"
|
||||
}
|
||||
},
|
||||
"tracking_type": {
|
||||
"name": "Tracking type",
|
||||
"state": {
|
||||
"connection": "Connection",
|
||||
"position": "Position"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.3.1",
|
||||
"cached-ipaddress==1.1.2"
|
||||
"aiodiscover==3.2.4",
|
||||
"cached-ipaddress==1.1.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
|
||||
tcp_port=entry.options[CONF_PORT],
|
||||
udp_port=entry.options[CONF_PORT],
|
||||
)
|
||||
queries.append(resolver_ipv4.query_dns(hostname, "A"))
|
||||
queries.append(resolver_ipv4.query(hostname, "A"))
|
||||
|
||||
if entry.data[CONF_IPV6]:
|
||||
resolver_ipv6 = aiodns.DNSResolver(
|
||||
@@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
|
||||
tcp_port=entry.options[CONF_PORT_IPV6],
|
||||
udp_port=entry.options[CONF_PORT_IPV6],
|
||||
)
|
||||
queries.append(resolver_ipv6.query_dns(hostname, "AAAA"))
|
||||
queries.append(resolver_ipv6.query(hostname, "AAAA"))
|
||||
|
||||
async def _close_resolvers() -> None:
|
||||
if resolver_ipv4 is not None:
|
||||
@@ -111,6 +111,10 @@ async def async_migrate_entry(
|
||||
) -> bool:
|
||||
"""Migrate old entry to a newer version."""
|
||||
|
||||
if config_entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version < 2 and config_entry.minor_version < 2:
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s",
|
||||
|
||||
@@ -72,7 +72,7 @@ async def async_validate_hostname(
|
||||
_resolver = aiodns.DNSResolver(
|
||||
nameservers=[resolver], udp_port=port, tcp_port=port
|
||||
)
|
||||
result = bool(await _resolver.query_dns(hostname, qtype))
|
||||
result = bool(await _resolver.query(hostname, qtype))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import aiodns
|
||||
from aiodns.error import DNSError
|
||||
import pycares
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import CONF_NAME, CONF_PORT
|
||||
@@ -149,7 +148,7 @@ class WanIpSensor(SensorEntity):
|
||||
response = None
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
response = await self._resolver.query_dns(self.hostname, self.querytype)
|
||||
response = await self._resolver.query(self.hostname, self.querytype)
|
||||
except TimeoutError as err:
|
||||
_LOGGER.debug("Timeout while resolving host: %s", err)
|
||||
await self._resolver.close()
|
||||
@@ -158,19 +157,9 @@ class WanIpSensor(SensorEntity):
|
||||
await self._resolver.close()
|
||||
|
||||
if response:
|
||||
if TYPE_CHECKING:
|
||||
assert all(
|
||||
isinstance(res.data, (pycares.ARecordData, pycares.AAAARecordData))
|
||||
for res in response.answer
|
||||
)
|
||||
_ips = []
|
||||
for res in response.answer:
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(
|
||||
res.data, (pycares.ARecordData, pycares.AAAARecordData)
|
||||
)
|
||||
_ips.append(res.data.addr)
|
||||
sorted_ips = sort_ips(_ips, querytype=self.querytype)
|
||||
sorted_ips = sort_ips(
|
||||
[res.host for res in response], querytype=self.querytype
|
||||
)
|
||||
self._attr_native_value = sorted_ips[0]
|
||||
self._attr_extra_state_attributes["ip_addresses"] = sorted_ips
|
||||
self._attr_available = True
|
||||
|
||||
@@ -13,6 +13,7 @@ from dsmr_parser.clients.rfxtrx_protocol import (
|
||||
from dsmr_parser.objects import DSMRObject
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
@@ -22,7 +23,6 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SerialPortSelector
|
||||
|
||||
from .const import (
|
||||
CONF_DSMR_VERSION,
|
||||
@@ -37,6 +37,8 @@ from .const import (
|
||||
RFXTRX_DSMR_PROTOCOL,
|
||||
)
|
||||
|
||||
CONF_MANUAL_PATH = "Enter Manually"
|
||||
|
||||
|
||||
class DSMRConnection:
|
||||
"""Test the connection to DSMR and receive telegram to read serial ids."""
|
||||
@@ -163,6 +165,8 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_dsmr_version: str | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@@ -218,13 +222,34 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Step when setting up serial configuration."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
data = await self.async_validate_dsmr(user_input, errors)
|
||||
user_selection = user_input[CONF_PORT]
|
||||
if user_selection == CONF_MANUAL_PATH:
|
||||
self._dsmr_version = user_input[CONF_DSMR_VERSION]
|
||||
return await self.async_step_setup_serial_manual_path()
|
||||
|
||||
dev_path = user_selection
|
||||
|
||||
validate_data = {
|
||||
CONF_PORT: dev_path,
|
||||
CONF_DSMR_VERSION: user_input[CONF_DSMR_VERSION],
|
||||
}
|
||||
|
||||
data = await self.async_validate_dsmr(validate_data, errors)
|
||||
if not errors:
|
||||
return self.async_create_entry(title=data[CONF_PORT], data=data)
|
||||
|
||||
ports = await usb.async_scan_serial_ports(self.hass)
|
||||
list_of_ports = {
|
||||
port.device: f"{port.device} - {port.description or 'n/a'}"
|
||||
f", s/n: {port.serial_number or 'n/a'}"
|
||||
+ (f" - {port.manufacturer}" if port.manufacturer else "")
|
||||
for port in ports
|
||||
}
|
||||
list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PORT): SerialPortSelector(),
|
||||
vol.Required(CONF_PORT): vol.In(list_of_ports),
|
||||
vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS),
|
||||
}
|
||||
)
|
||||
@@ -234,6 +259,27 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_setup_serial_manual_path(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select path manually."""
|
||||
if user_input is not None:
|
||||
validate_data = {
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_DSMR_VERSION: self._dsmr_version,
|
||||
}
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
data = await self.async_validate_dsmr(validate_data, errors)
|
||||
if not errors:
|
||||
return self.async_create_entry(title=data[CONF_PORT], data=data)
|
||||
|
||||
schema = vol.Schema({vol.Required(CONF_PORT): str})
|
||||
return self.async_show_form(
|
||||
step_id="setup_serial_manual_path",
|
||||
data_schema=schema,
|
||||
)
|
||||
|
||||
async def async_validate_dsmr(
|
||||
self, input_data: dict[str, Any], errors: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
},
|
||||
"title": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"setup_serial_manual_path": {
|
||||
"data": {
|
||||
"port": "[%key:common::config_flow::data::usb_path%]"
|
||||
},
|
||||
"title": "[%key:common::config_flow::data::path%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"type": "Connection type"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user