Merge remote-tracking branch 'origin/dev' into frenck/probatio-validation-engine

# Conflicts:
#	homeassistant/package_constraints.txt
#	pyproject.toml
#	requirements.txt
#	tests/syrupy.py
This commit is contained in:
Franck Nijhof
2026-08-18 11:13:19 +00:00
4206 changed files with 230917 additions and 37271 deletions
@@ -1,77 +0,0 @@
---
name: quality-scale-rule-verifier
description: |
Use this agent when you need to verify that a Home Assistant integration follows a specific quality scale rule. This includes checking if the integration implements required patterns, configurations, or code structures defined by the quality scale system.
<example>
Context: The user wants to verify if an integration follows a specific quality scale rule.
user: "Check if the peblar integration follows the config-flow rule"
assistant: "I'll use the quality scale rule verifier to check if the peblar integration properly implements the config-flow rule."
<commentary>
Since the user is asking to verify a quality scale rule implementation, use the quality-scale-rule-verifier agent.
</commentary>
</example>
<example>
Context: The user is reviewing if an integration reaches a specific quality scale level.
user: "Verify that this integration reaches the bronze quality scale"
assistant: "Let me use the quality scale rule verifier to check the bronze quality scale implementation."
<commentary>
The user wants to verify the integration has reached a certain quality level, so use multiple quality-scale-rule-verifier agents to verify each bronze rule.
</commentary>
</example>
model: inherit
color: yellow
tools: Read, Bash, Grep, Glob, WebFetch
---
You are an expert Home Assistant integration quality scale auditor specializing in verifying compliance with specific quality scale rules. You have deep knowledge of Home Assistant's architecture, best practices, and the quality scale system that ensures integration consistency and reliability.
You will verify if an integration follows a specific quality scale rule by:
1. **Fetching Rule Documentation**: Retrieve the official rule documentation from:
`https://raw.githubusercontent.com/home-assistant/developers.home-assistant/refs/heads/master/docs/core/integration-quality-scale/rules/{rule_name}.md`
where `{rule_name}` is the rule identifier (e.g., 'config-flow', 'entity-unique-id', 'parallel-updates')
2. **Understanding Rule Requirements**: Parse the rule documentation to identify:
- Core requirements and mandatory implementations
- Specific code patterns or configurations required
- Common violations and anti-patterns
- Exemption criteria (when a rule might not apply)
- The quality tier this rule belongs to (Bronze, Silver, Gold, Platinum)
3. **Analyzing Integration Code**: Examine the integration's codebase at `homeassistant/components/<integration domain>` focusing on:
- `manifest.json` for quality scale declaration and configuration
- `quality_scale.yaml` for rule status (done, todo, exempt)
- Relevant Python modules based on the rule requirements
- Configuration files and service definitions as needed
4. **Verification Process**:
- Check if the rule is marked as 'done', 'todo', or 'exempt' in quality_scale.yaml
- If marked 'exempt', verify the exemption reason is valid
- If marked 'done', verify the actual implementation matches requirements
- Identify specific files and code sections that demonstrate compliance or violations
- Consider the integration's declared quality tier when applying rules
- To fetch the integration docs, use WebFetch to fetch from `https://raw.githubusercontent.com/home-assistant/home-assistant.io/refs/heads/current/source/_integrations/<integration domain>.markdown`
- To fetch information about a PyPI package, use the URL `https://pypi.org/pypi/<package>/json`
5. **Reporting Findings**: Provide a comprehensive verification report that includes:
- **Rule Summary**: Brief description of what the rule requires
- **Compliance Status**: Clear pass/fail/exempt determination
- **Evidence**: Specific code examples showing compliance or violations
- **Issues Found**: Detailed list of any non-compliance issues with file locations
- **Recommendations**: Actionable steps to achieve compliance if needed
- **Exemption Analysis**: If applicable, whether the exemption is justified
When examining code, you will:
- Look for exact implementation patterns specified in the rule
- Verify all required components are present and properly configured
- Check for common mistakes and anti-patterns
- Consider edge cases and error handling requirements
- Validate that implementations follow Home Assistant conventions
You will be thorough but focused, examining only the aspects relevant to the specific rule being verified. You will provide clear, actionable feedback that helps developers understand both what needs to be fixed and why it matters for integration quality.
If you cannot access the rule documentation or find the integration code, clearly state what information is missing and what you would need to complete the verification.
Remember that quality scale rules are cumulative - Bronze rules apply to all integrations with a quality scale, Silver rules apply to Silver+ integrations, and so on. Always consider the integration's target quality level when determining which rules should be enforced.
+5 -5
View File
@@ -19,7 +19,7 @@ Follow these systematic steps to successfully bump a python package requirement
- [ ] **2. Discover Codebase References**: Search the codebase to find all `manifest.json` and requirements files referencing the package.
- [ ] **3. Resolve Version/Tag Details**: Run the integrated validation helper script to resolve version details, GitHub repo, release tag format, and formatted PR links:
```bash
uv run python3 ./.claude/skills/bump-dependency/scripts/resolve_dependency.py <package> <old_version> [--new-version <new_version>]
uv run --no-sync python3 ./.claude/skills/bump-dependency/scripts/resolve_dependency.py <package> <old_version> [--new-version <new_version>]
```
- [ ] **4. Plan-Validate-Execute (Draft Plan)**: Before modifying any files, write a brief, structured plan outlining the integrations to change, old version, new version, and the resolved comparison link. Show this draft plan to the user.
@@ -33,7 +33,7 @@ Follow these systematic steps to successfully bump a python package requirement
- [ ] **7. Apply Bump to manifests**: Update the version constraint string in all identified `manifest.json` files (e.g., change `"package==1.0.0"` to `"package==1.1.0"`).
- [ ] **8. Regenerate Core Requirements**: Run the requirements generator to update all derivative requirements and constraint files:
```bash
uv run python3 -m script.gen_requirements_all
uv run --no-sync python3 -m script.gen_requirements_all
```
- [ ] **9. Validate Requirements**: Check `git diff` to ensure that only the targeted `manifest.json` files and `requirements_all.txt` (and potentially standard constraints) were modified. No unrelated files must be affected.
- [ ] **10. Local Venv Verification**: Install the exact targeted package version directly inside the virtual environment:
@@ -44,14 +44,14 @@ Follow these systematic steps to successfully bump a python package requirement
### Phase C: Validation Loop (Tests & Lint)
- [ ] **11. Run Integration Tests**: Execute the pytest suite for all integrations that consume the bumped package:
```bash
uv run pytest tests/components/<integration_name>
uv run --no-sync pytest tests/components/<integration_name>
```
- *Validation Loop*: If tests fail, analyze the error, apply appropriate fixes, and re-run pytest until all tests pass cleanly.
- [ ] **12. Run prek Lint Checks**: Run the local prek hooks on modified files:
```bash
uv run prek run
uv run --no-sync prek run
```
- *Validation Loop*: If prek checks report any formatting or linting violations, fix them and repeat `uv run prek run` until it passes completely without errors.
- *Validation Loop*: If prek checks report any formatting or linting violations, fix them and repeat `uv run --no-sync prek run` until it passes completely without errors.
### Phase D: User Confirmation & PR Creation
- [ ] **13. Commit Changes**: Commit the clean changes:
+1 -1
View File
@@ -8,7 +8,7 @@ description: Reviews Home Assistant GitHub pull requests and provides feedback c
## Instructions:
- Use 'gh pr view' to get the PR details and description.
- Use 'gh pr diff' to see all the changes in the PR.
- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions.
- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. Explicitly pass the PR's target/base branch to the `ha-review` skill (obtained via `gh pr view`) so it diffs against the correct base.
- Run a subagent in parallel to check the PR review comments following the `ha-pr-comment-audit` skill.
## IMPORTANT:
@@ -0,0 +1,52 @@
---
name: ha-quality-scale-verify
description: Verifies that a Home Assistant integration follows a specific quality scale rule, checking whether it implements the required patterns, configurations, or code structures defined by the quality scale system. Use when asked to check a rule (e.g. "check if the peblar integration follows the config-flow rule") or to verify an integration reaches a quality tier (Bronze, Silver, Gold, Platinum).
---
# Verify Quality Scale Rule
You are verifying whether a Home Assistant integration follows a specific quality scale rule. Verify one rule at a time; to check a full tier, verify each of that tier's rules (run in parallel subagents when possible).
## 1. Fetch rule documentation
Retrieve the official rule documentation from:
`https://raw.githubusercontent.com/home-assistant/developers.home-assistant/refs/heads/master/docs/core/integration-quality-scale/rules/{rule_name}.md`
where `{rule_name}` is the rule identifier (e.g. `config-flow`, `entity-unique-id`, `parallel-updates`).
## 2. Understand rule requirements
Parse the rule documentation to identify:
- Core requirements and mandatory implementations
- Specific code patterns or configurations required
- Common violations and anti-patterns
- Exemption criteria (when a rule might not apply)
- The quality tier this rule belongs to (Bronze, Silver, Gold, Platinum)
## 3. Analyze the integration code
Examine the integration's codebase at `homeassistant/components/<integration domain>`, focusing on:
- `manifest.json` for quality scale declaration and configuration
- `quality_scale.yaml` for rule status (done, todo, exempt)
- Relevant Python modules based on the rule requirements
- Configuration files and service definitions as needed
Additional sources:
- Integration docs: Fetch `https://raw.githubusercontent.com/home-assistant/home-assistant.io/refs/heads/current/source/_integrations/<integration domain>.markdown`
- PyPI package info: `https://pypi.org/pypi/<package>/json`
## 4. Verification process
- Check if the rule is marked `done`, `todo`, or `exempt` in `quality_scale.yaml`
- If marked `exempt`, verify the exemption reason is valid
- If marked `done`, verify the actual implementation matches the requirements
- Identify specific files and code sections that demonstrate compliance or violations
- Consider the integration's declared quality tier when applying rules
- Look for the exact implementation patterns specified in the rule
- Check for common mistakes, anti-patterns, edge cases, and error handling requirements
- Validate that implementations follow Home Assistant conventions
Quality scale rules are cumulative: Bronze rules apply to all integrations with a quality scale, Silver rules apply to Silver+ integrations, and so on. Always consider the integration's target quality level when determining which rules to enforce.
## 5. Report findings
Report only the rules that have issues. Do not list rules that pass or that are validly exempt. For each rule with an issue, provide:
- **Rule**: The rule identifier and the problem (non-compliance, or an invalid/unjustified exemption)
- **Evidence**: Specific file locations and code showing the violation
- **Recommendation**: Actionable steps to achieve compliance
If no rules have issues, say so in a single line. Be thorough but focused: examine only the aspects relevant to the rules being verified. If you cannot access the rule documentation or find the integration code, clearly state what information is missing and what you would need to complete the verification.
+7
View File
@@ -5,6 +5,9 @@ description: Reviews Home Assistant code changes and provides constructive feedb
# Review Code Changes
## Scope:
- Unless instructed otherwise, review the full changes (the ones from the branch plus uncommitted ones) against the target branch. Resolve the base to an available ref (prefer `upstream/<base>`, then `origin/<base>`, then local `<base>`) and review `git diff "$(git merge-base "$BASE_REF" HEAD)"`; use `dev` as the default base.
## Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
@@ -13,6 +16,10 @@ description: Reviews Home Assistant code changes and provides constructive feedb
- Test coverage
- Documentation updates if needed
## Quality scale:
- If the changes include a `quality_scale.yaml` file, run a subagent to verify all the added or modified rules, following the `ha-quality-scale-verify` skill.
- Include the verification results in the final review comments.
## 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.
-49
View File
@@ -1,49 +0,0 @@
<!-- READ THIS FIRST:
- If you need additional help with this template, please refer to https://www.home-assistant.io/help/reporting_issues/
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/core/releases
- Do not report issues for integrations if you are using custom components or integrations.
- Provide as many details as possible. Paste logs, configuration samples and code into the backticks.
DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed without comment.
-->
## The problem
<!--
Describe the issue you are experiencing here to communicate to the
maintainers. Tell us what you were trying to do and what happened.
-->
## Environment
<!--
Provide details about the versions you are using, which helps us to reproduce
and find the issue quicker. Version information is found in the
Home Assistant frontend: Settings -> About.
-->
- Home Assistant Core release with the issue:
- Last working Home Assistant Core release (if known):
- Operating environment (OS/Container/Supervised/Core):
- Integration causing this issue:
- Link to integration documentation on our website:
## Problem-relevant `configuration.yaml`
<!--
An example configuration that caused the problem for you. Fill this out even
if it seems unimportant to you. Please be sure to remove personal information
like passwords, private URLs and other credentials.
-->
```yaml
```
## Traceback/Error logs
<!--
If you come across any trace or error logs, please provide them.
-->
```txt
```
## Additional information
+2 -1
View File
@@ -57,7 +57,8 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://developers.home-assistant.io/docs/ai_policy
-->
- [ ] I understand the code I am submitting and can explain how it works.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 102 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"entries": {
"github/gh-aw-actions/setup-cli@v0.83.4": {
"repo": "github/gh-aw-actions/setup-cli",
"version": "v0.83.4",
"sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7"
},
"github/gh-aw-actions/setup@v0.83.4": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.83.4",
"sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7"
}
}
}
+17 -6
View File
@@ -8,7 +8,7 @@
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement.
- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments and Markdown link reference definitions (lines of the form `[name]: url`), which do not render and cannot be verified from the description. Even unchecked checkboxes or empty sections must be present. This is a hard requirement.
## Pull Request template
@@ -74,7 +74,8 @@ The PR description must follow this template (from `.github/PULL_REQUEST_TEMPLAT
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://developers.home-assistant.io/docs/ai_policy
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -149,9 +150,10 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Development Commands
- Run "python3" in current virtual environment to ensure the correct Python version is used for testing.
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing. If uv reports that no download was found for the required Python version, the environment is running an outdated version of uv; upgrade it with `curl -LsSf https://astral.sh/uv/install.sh | sh` and run `script/setup` again.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
- After finishing a code session, run `uv run --no-sync prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
@@ -161,8 +163,8 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Testing
- Use `uv run pytest` to run tests
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
- Use `uv run --no-sync pytest` to run tests
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
- When writing or modifying tests, ensure all test function parameters have type annotations.
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
@@ -177,6 +179,15 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before. Comments in tests that explain why a function call or assertion is made are ok.
- Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading.
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
- Sensitive service actions, i.e. those that can change configuration or have security implications, should require an admin user. Register them with the `async_register_admin_service` service helper, which checks this for you.
## AI policy
This project follows the [Open Home Foundation AI Policy](AI_POLICY.md).
Autonomous contributions are not accepted: a human must review, understand,
and be able to explain every change before it is submitted. Do not open
issues or pull requests autonomously, and do not post comments on behalf of
a user without their review.
+12
View File
@@ -7,10 +7,13 @@
"pip_requirements",
"pre-commit",
"dockerfile",
"npm",
"custom.regex",
"homeassistant-manifest"
],
"ignorePaths": ["**/node_modules/**"],
"pre-commit": {
"enabled": true
},
@@ -180,6 +183,15 @@
"enabled": true,
"labels": ["dependency"]
},
{
"description": "pnpm version pinned in the E2E tests packageManager field (allowlisted)",
"matchManagers": ["npm"],
"matchFileNames": ["tests/e2e/package.json"],
"matchDepTypes": ["packageManager"],
"matchPackageNames": ["pnpm"],
"enabled": true,
"labels": ["dependency"]
},
{
"description": "For types-* stubs, only allow patch updates. Major/minor bumps track the upstream runtime package version and must be manually coordinated with the corresponding pin.",
"matchPackageNames": ["/^types-/"],
+3
View File
@@ -0,0 +1,3 @@
{
"maintenance": false
}
+41 -31
View File
@@ -16,6 +16,10 @@ env:
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.07.0"
ARCHITECTURES: '["amd64", "aarch64"]'
MACHINES: >-
["generic-x86-64", "khadas-vim3", "odroid-c2", "odroid-c4", "odroid-m1",
"odroid-n2", "qemuarm-64", "qemux86-64", "raspberrypi3-64",
"raspberrypi4-64", "raspberrypi5-64", "yellow", "green"]
permissions: {}
@@ -35,15 +39,16 @@ jobs:
channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }}
architectures: ${{ env.ARCHITECTURES }}
machines: ${{ env.MACHINES }}
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
@@ -102,7 +107,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -130,7 +135,7 @@ jobs:
- name: Set up Python
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
@@ -218,20 +223,7 @@ jobs:
id-token: write # For cosign signing
strategy:
matrix:
machine:
- generic-x86-64
- khadas-vim3
- odroid-c2
- odroid-c4
- odroid-m1
- odroid-n2
- qemuarm-64
- qemux86-64
- raspberrypi3-64
- raspberrypi4-64
- raspberrypi5-64
- yellow
- green
machine: ${{ fromJson(needs.init.outputs.machines) }}
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
@@ -245,7 +237,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -282,17 +274,35 @@ jobs:
push: true
version: ${{ needs.init.outputs.version }}
run_e2e_tests_base:
name: Run E2E tests on base images
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
uses: ./.github/workflows/e2e-tests.yml
with:
version: ${{ needs.init.outputs.version }}
image_prefixes: ${{ needs.init.outputs.architectures }}
run_e2e_tests_machine:
name: Run E2E tests on machine images
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"]
uses: ./.github/workflows/e2e-tests.yml
with:
version: ${{ needs.init.outputs.version }}
image_prefixes: ${{ needs.init.outputs.machines }}
publish_ha:
name: Publish version files
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"]
needs: ["init", "build_machine", "run_e2e_tests_machine"]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -326,7 +336,7 @@ jobs:
name: Publish to ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
needs: ["init", "build_base", "run_e2e_tests_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
@@ -342,13 +352,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -461,7 +471,7 @@ jobs:
build_python:
name: Build PyPi package
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
needs: ["init", "build_base", "run_e2e_tests_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
@@ -469,12 +479,12 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
@@ -497,7 +507,7 @@ jobs:
python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
@@ -516,12 +526,12 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -550,7 +560,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
@@ -10,6 +10,8 @@ name: Check requirements (deterministic)
on:
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- master
paths:
- "requirements*.txt"
- "**/requirements*.txt"
@@ -37,11 +39,11 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
check-latest: true
File diff suppressed because one or more lines are too long
+5
View File
@@ -3,6 +3,11 @@ on:
workflow_run:
workflows: ["Check requirements (deterministic)"]
types: [completed]
# On workflow_run the actor is the PR author, so the default role gate
# (admin/maintainer/write) skips every PR from an outside contributor — which is
# exactly who this review is for. The job is read-only and its single safe-output
# is a sanitized comment on the PR recorded in the trusted upstream artifact.
roles: all
permissions:
contents: read
actions: read
+63 -26
View File
@@ -39,7 +39,7 @@ on:
env:
CACHE_VERSION: 4
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.8"
HA_SHORT_VERSION: "2026.9"
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)
@@ -100,7 +100,7 @@ jobs:
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Generate partial Python venv restore key
@@ -116,7 +116,7 @@ jobs:
# of a new uv cache entry after a version bump.
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
- name: Filter for core changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: core
with:
filters: .core_files.yaml
@@ -131,7 +131,7 @@ jobs:
echo "Result:"
cat .integration_paths.yaml
- name: Filter for integration changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: integrations
with:
filters: .integration_paths.yaml
@@ -271,7 +271,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Register problem matchers
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -298,11 +298,11 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
with:
extra-args: --all-files zizmor
@@ -325,7 +325,7 @@ jobs:
- script/hassfest/docker/Dockerfile
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Register hadolint problem matcher
@@ -348,7 +348,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }} and build venv
@@ -392,7 +392,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -429,7 +429,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python and restore venv
@@ -459,7 +459,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
@@ -487,7 +487,7 @@ jobs:
&& github.event_name == 'pull_request'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Dependency review
@@ -514,7 +514,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }} and restore venv
@@ -559,7 +559,7 @@ jobs:
|| github.event.inputs.pylint-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python and restore venv
@@ -606,7 +606,7 @@ jobs:
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python and restore venv
@@ -651,7 +651,7 @@ jobs:
|| github.event.inputs.mypy-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Generate partial mypy restore key
@@ -712,7 +712,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -734,12 +734,49 @@ jobs:
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
- name: Restore pytest test counts cache
id: cache-pytest-counts
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: pytest_test_counts.json
# Primary key is a sentinel; restore-keys pick the most recent
# prefix match since the real (content-addressed) key isn't
# known until split_tests.py runs below.
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-restore-sentinel
restore-keys: |
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
- name: Run split_tests.py
env:
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
run: |
. venv/bin/activate
python -m script.split_tests ${TEST_GROUP_COUNT} tests
python -m script.split_tests \
--cache pytest_test_counts.json \
${TEST_GROUP_COUNT} tests
- name: Hash pytest test counts cache
id: cache-pytest-counts-hash
run: |
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
>> "$GITHUB_OUTPUT"
- name: Save pytest test counts cache
# Content-addressed key: identical content reuses the same entry.
# Skip the save when the restore already matched that hash.
if: >-
!endsWith(
steps.cache-pytest-counts.outputs.cache-matched-key,
steps.cache-pytest-counts-hash.outputs.hash
)
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: pytest_test_counts.json
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-${{
steps.cache-pytest-counts-hash.outputs.hash }}
- name: Upload pytest_buckets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -770,7 +807,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -905,7 +942,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1048,7 +1085,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1180,7 +1217,7 @@ jobs:
if: needs.info.outputs.skip_coverage != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1218,7 +1255,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1333,7 +1370,7 @@ jobs:
- pytest-partial
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download all coverage artifacts
+3 -3
View File
@@ -23,16 +23,16 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
category: "/language:python"
+111
View File
@@ -0,0 +1,111 @@
name: E2E tests
# yamllint disable-line rule:truthy
on:
workflow_dispatch:
inputs:
version:
description: "Image tag or digest to test (e.g. dev, 2026.7.1, 2026.8.0b0, sha256:0a1b2c3d…)"
default: "dev"
required: true
image_prefixes:
description: "JSON array of image prefixes to test. Will be prefixed to `-homeassistant`"
default: >-
["generic-x86-64", "khadas-vim3", "odroid-c2", "odroid-c4",
"odroid-m1", "odroid-n2", "qemuarm-64", "qemux86-64",
"raspberrypi3-64", "raspberrypi4-64", "raspberrypi5-64",
"yellow", "green"]
required: true
workflow_call:
inputs:
version:
description: "Image tag or digest to test (e.g. dev, 2026.7.1, 2026.8.0b0, sha256:0a1b2c3d…)"
required: true
type: string
image_prefixes:
description: "JSON array of image prefixes to test. Will be prefixed to `-homeassistant`"
required: true
type: string
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version }}-${{ inputs.image_prefixes }}
cancel-in-progress: true
jobs:
boot_check:
name: Boot check ${{ matrix.image_prefix }} image
if: github.repository_owner == 'home-assistant'
# amd64 images need an x86 runner; all others are aarch64
runs-on: ${{ contains(fromJson('["amd64", "generic-x86-64", "qemux86-64"]'), matrix.image_prefix) && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
image_prefix: ${{ fromJson(inputs.image_prefixes) }}
env:
BASE_URL: http://localhost:8123
services:
homeassistant:
# Digests are referenced with '@', tags with ':'
image: ghcr.io/home-assistant/${{ matrix.image_prefix }}-homeassistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images]
ports:
- 8123:8123
# Gate steps until Home Assistant answers (60 x 5s ≈ 300s startup budget)
options: >-
--health-cmd="curl --fail --silent --max-time 10 --output /dev/null http://127.0.0.1:8123/"
--health-start-period=10s
--health-interval=5s
--health-retries=60
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
package_json_file: tests/e2e/package.json
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: pnpm
cache-dependency-path: tests/e2e/pnpm-lock.yaml
- name: Install E2E test dependencies
working-directory: tests/e2e
run: pnpm install --frozen-lockfile
- name: Install Playwright browser
working-directory: tests/e2e
run: pnpm exec playwright install --with-deps chromium
- name: Run Playwright E2E tests
working-directory: tests/e2e
run: pnpm exec playwright test
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-${{ matrix.image_prefix }}
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
- name: Dump container logs
if: always()
env:
CONTAINER: ${{ job.services.homeassistant.id }}
run: docker logs "$CONTAINER" > homeassistant.log 2>&1 || true
- name: Upload container logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: container-logs-${{ matrix.image_prefix }}
path: homeassistant.log
+5 -3
View File
@@ -27,9 +27,11 @@ jobs:
- 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
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
permission-issues: write
permission-pull-requests: write
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
@@ -42,7 +44,7 @@ jobs:
# - Issues
# - No issues marked as no-stale or help-wanted
- name: 60 days stale PRs policy and 90 days stale issue policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
remove-stale-when-updated: true
+2 -2
View File
@@ -22,12 +22,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
+4 -4
View File
@@ -29,13 +29,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
check-latest: true
@@ -116,7 +116,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -167,7 +167,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+4
View File
@@ -145,3 +145,7 @@ pytest_buckets.txt
.claude/worktrees/
.serena/
# Playwright e2e tests
tests/e2e/node_modules/
tests/e2e/playwright-report/
tests/e2e/test-results/
+3 -3
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20
rev: v0.16.2
hooks:
- id: ruff-check
args:
@@ -8,7 +8,7 @@ repos:
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
rev: v2.4.3
hooks:
- id: codespell
args:
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
rev: v1.29.0
hooks:
- id: zizmor
args:
+1
View File
@@ -5,3 +5,4 @@ homeassistant/generated/*
tests/components/lidarr/fixtures/initialize.js
tests/components/lidarr/fixtures/initialize-wrong.js
tests/fixtures/core/config/yaml_errors/
tests/e2e/pnpm-lock.yaml
+11 -1
View File
@@ -195,6 +195,7 @@ homeassistant.components.elgato.*
homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*
homeassistant.components.energenie_power_sockets.*
homeassistant.components.energieleser.*
homeassistant.components.energy.*
homeassistant.components.energyid.*
homeassistant.components.energyzero.*
@@ -215,6 +216,7 @@ homeassistant.components.filter.*
homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flow_it.*
homeassistant.components.flux_led.*
homeassistant.components.folder_watcher.*
homeassistant.components.forecast_solar.*
@@ -228,6 +230,7 @@ homeassistant.components.fujitsu_fglair.*
homeassistant.components.fully_kiosk.*
homeassistant.components.fumis.*
homeassistant.components.fyta.*
homeassistant.components.gatus.*
homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.*
@@ -255,6 +258,7 @@ homeassistant.components.guntamatic.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
homeassistant.components.harman_luxury.*
homeassistant.components.hdfury.*
homeassistant.components.heos.*
homeassistant.components.here_travel_time.*
@@ -281,6 +285,8 @@ homeassistant.components.homekit_controller.storage
homeassistant.components.homekit_controller.utils
homeassistant.components.homewizard.*
homeassistant.components.homeworks.*
homeassistant.components.hortimax.*
homeassistant.components.hotspring.*
homeassistant.components.hr_energy_qube.*
homeassistant.components.http.*
homeassistant.components.huawei_lte.*
@@ -336,6 +342,7 @@ homeassistant.components.lawn_mower.*
homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.led_infrared.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
@@ -347,8 +354,10 @@ homeassistant.components.lifx.*
homeassistant.components.light.*
homeassistant.components.linkplay.*
homeassistant.components.litejet.*
homeassistant.components.litellm.*
homeassistant.components.litterrobot.*
homeassistant.components.llama_cpp.*
homeassistant.components.llm.*
homeassistant.components.local_ip.*
homeassistant.components.local_todo.*
homeassistant.components.lock.*
@@ -380,7 +389,6 @@ homeassistant.components.min_max.*
homeassistant.components.minecraft_server.*
homeassistant.components.mjpeg.*
homeassistant.components.modbus.*
homeassistant.components.modbus_connection.*
homeassistant.components.modem_callerid.*
homeassistant.components.mold_indicator.*
homeassistant.components.monzo.*
@@ -405,6 +413,7 @@ homeassistant.components.nfandroidtv.*
homeassistant.components.nightscout.*
homeassistant.components.nissan_leaf.*
homeassistant.components.no_ip.*
homeassistant.components.nobo_hub.*
homeassistant.components.nordpool.*
homeassistant.components.notify.*
homeassistant.components.notion.*
@@ -619,6 +628,7 @@ homeassistant.components.velux.*
homeassistant.components.victron_gx.*
homeassistant.components.vistapool.*
homeassistant.components.vivotek.*
homeassistant.components.vizio.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
homeassistant.components.volvo.*
+14 -4
View File
@@ -13,9 +13,10 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Development Commands
- Run "python3" in current virtual environment to ensure the correct Python version is used for testing.
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing. If uv reports that no download was found for the required Python version, the environment is running an outdated version of uv; upgrade it with `curl -LsSf https://astral.sh/uv/install.sh | sh` and run `script/setup` again.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
- After finishing a code session, run `uv run --no-sync prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
@@ -25,8 +26,8 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Testing
- Use `uv run pytest` to run tests
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
- Use `uv run --no-sync pytest` to run tests
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
- When writing or modifying tests, ensure all test function parameters have type annotations.
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
@@ -41,6 +42,15 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before. Comments in tests that explain why a function call or assertion is made are ok.
- Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading.
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
- Sensitive service actions, i.e. those that can change configuration or have security implications, should require an admin user. Register them with the `async_register_admin_service` service helper, which checks this for you.
## AI policy
This project follows the [Open Home Foundation AI Policy](AI_POLICY.md).
Autonomous contributions are not accepted: a human must review, understand,
and be able to explain every change before it is submitted. Do not open
issues or pull requests autonomously, and do not post comments on behalf of
a user without their review.
+45
View File
@@ -0,0 +1,45 @@
# Open Home Foundation - AI Policy
We support using AI (i.e., LLMs) as tools when contributing to Open Home Foundation projects. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects.
Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted.
## Autonomous agents
**We do not allow autonomous agents to be used for contributing to our projects.** We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates.
## Communication on issues, pull requests, and code reviews
We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output.
If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy.
**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own.
If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets.
## Non-native English speakers
We understand that AI is useful when communicating as a non-native English speaker. Using AI to improve the grammar or clarity of text you have written yourself is fine. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable.
## Code and documentation contributions
AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI.
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
## Our use of AI
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
## Enforcement
Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to OHF projects. If you believe your contribution was closed in error, you are welcome to reach out to a maintainer to discuss.
---
The canonical version of this policy is published at
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
the published version applies.
Generated
+47 -16
View File
@@ -215,8 +215,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/azure_service_bus/ @hfurubotten
/homeassistant/components/azure_storage/ @zweckj
/tests/components/azure_storage/ @zweckj
/homeassistant/components/backblaze_b2/ @hugo-vrijswijk @ElCruncharino
/tests/components/backblaze_b2/ @hugo-vrijswijk @ElCruncharino
/homeassistant/components/backblaze_b2/ @ElCruncharino
/tests/components/backblaze_b2/ @ElCruncharino
/homeassistant/components/backup/ @home-assistant/core
/tests/components/backup/ @home-assistant/core
/homeassistant/components/baf/ @bdraco @jfroy
@@ -441,6 +441,8 @@ CLAUDE.md @home-assistant/core
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192
/homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234
/homeassistant/components/dyson_infrared/ @elax46
/tests/components/dyson_infrared/ @elax46
/homeassistant/components/eafm/ @Jc2k
/tests/components/eafm/ @Jc2k
/homeassistant/components/earn_e_p1/ @Miggets7
@@ -576,6 +578,8 @@ CLAUDE.md @home-assistant/core
/tests/components/flipr/ @cnico
/homeassistant/components/flo/ @dmulcahey
/tests/components/flo/ @dmulcahey
/homeassistant/components/flow_it/ @albertogeniola
/tests/components/flow_it/ @albertogeniola
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss @Marcello17
@@ -607,6 +611,8 @@ CLAUDE.md @home-assistant/core
/tests/components/frontend/ @home-assistant/frontend
/homeassistant/components/frontier_silicon/ @wlcrs
/tests/components/frontier_silicon/ @wlcrs
/homeassistant/components/fuelprices_dk/ @MTrab
/tests/components/fuelprices_dk/ @MTrab
/homeassistant/components/fujitsu_fglair/ @crevetor
/tests/components/fujitsu_fglair/ @crevetor
/homeassistant/components/fully_kiosk/ @cgarwood
@@ -623,6 +629,8 @@ CLAUDE.md @home-assistant/core
/tests/components/gardena_bluetooth/ @elupus
/homeassistant/components/gate/ @home-assistant/core
/tests/components/gate/ @home-assistant/core
/homeassistant/components/gatus/ @TN-1
/tests/components/gatus/ @TN-1
/homeassistant/components/gdacs/ @exxamalte
/tests/components/gdacs/ @exxamalte
/homeassistant/components/generic/ @davet2001
@@ -645,6 +653,8 @@ CLAUDE.md @home-assistant/core
/tests/components/geonetnz_quakes/ @exxamalte
/homeassistant/components/geonetnz_volcano/ @exxamalte
/tests/components/geonetnz_volcano/ @exxamalte
/homeassistant/components/geosphere_austria_warnings/ @tklecka
/tests/components/geosphere_austria_warnings/ @tklecka
/homeassistant/components/ghost/ @johnonolan
/tests/components/ghost/ @johnonolan
/homeassistant/components/gios/ @bieniu
@@ -715,10 +725,14 @@ CLAUDE.md @home-assistant/core
/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hanna/ @bestycame
/tests/components/hanna/ @bestycame
/homeassistant/components/harbor/ @Lash-L @afgarcia86
/tests/components/harbor/ @Lash-L @afgarcia86
/homeassistant/components/hardkernel/ @home-assistant/core
/tests/components/hardkernel/ @home-assistant/core
/homeassistant/components/hardware/ @home-assistant/core
/tests/components/hardware/ @home-assistant/core
/homeassistant/components/harman_luxury/ @sbesh91
/tests/components/harman_luxury/ @sbesh91
/homeassistant/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/homeassistant/components/hassio/ @home-assistant/supervisor
@@ -785,6 +799,10 @@ CLAUDE.md @home-assistant/core
/tests/components/honeywell/ @mkmer
/homeassistant/components/honeywell_string_lights/ @balloob
/tests/components/honeywell_string_lights/ @balloob
/homeassistant/components/hortimax/ @wildekek
/tests/components/hortimax/ @wildekek
/homeassistant/components/hotspring/ @Moustachauve
/tests/components/hotspring/ @Moustachauve
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
@@ -878,8 +896,8 @@ CLAUDE.md @home-assistant/core
/tests/components/input_select/ @home-assistant/core
/homeassistant/components/input_text/ @home-assistant/core
/tests/components/input_text/ @home-assistant/core
/homeassistant/components/insteon/ @teharris1 @ssyrell
/tests/components/insteon/ @teharris1 @ssyrell
/homeassistant/components/insteon/ @teharris1 @ssyrell @connorgallopo
/tests/components/insteon/ @teharris1 @ssyrell @connorgallopo
/homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
@@ -891,6 +909,7 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/tests/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs
/homeassistant/components/ios/ @robbiet480
@@ -940,6 +959,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/kaiterra/ @Michsior14
/homeassistant/components/kaleidescape/ @SteveEasley
/tests/components/kaleidescape/ @SteveEasley
/homeassistant/components/karakeep/ @sli-cka
/tests/components/karakeep/ @sli-cka
/homeassistant/components/keba/ @dannerph
/homeassistant/components/keenetic_ndms2/ @foxel
/tests/components/keenetic_ndms2/ @foxel
@@ -995,6 +1016,8 @@ CLAUDE.md @home-assistant/core
/tests/components/leaone/ @bdraco
/homeassistant/components/led_ble/ @bdraco
/tests/components/led_ble/ @bdraco
/homeassistant/components/led_infrared/ @tr4nt0r
/tests/components/led_infrared/ @tr4nt0r
/homeassistant/components/lektrico/ @lektrico
/tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom
@@ -1024,6 +1047,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/linux_battery/ @fabaff
/homeassistant/components/litejet/ @joncar
/tests/components/litejet/ @joncar
/homeassistant/components/litellm/ @luismalves
/tests/components/litellm/ @luismalves
/homeassistant/components/litterrobot/ @natekspencer @tkdrob
/tests/components/litterrobot/ @natekspencer @tkdrob
/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet
@@ -1066,6 +1091,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lutron/ @cdheiser @wilburCForce
/homeassistant/components/lutron_caseta/ @swails @danaues @eclair4151
/tests/components/lutron_caseta/ @swails @danaues @eclair4151
/homeassistant/components/lyngdorf/ @fishloa
/tests/components/lyngdorf/ @fishloa
/homeassistant/components/lyric/ @timmo001
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
@@ -1118,6 +1145,8 @@ CLAUDE.md @home-assistant/core
/tests/components/metoffice/ @MrHarcombe @avee87
/homeassistant/components/microbees/ @microBeesTech
/tests/components/microbees/ @microBeesTech
/homeassistant/components/midea/ @chemelli74 @rokam @caibinqing
/tests/components/midea/ @chemelli74 @rokam @caibinqing
/homeassistant/components/miele/ @astrandb
/tests/components/miele/ @astrandb
/homeassistant/components/mikrotik/ @engrbm87 @chemelli74
@@ -1136,8 +1165,6 @@ CLAUDE.md @home-assistant/core
/tests/components/moat/ @bdraco
/homeassistant/components/mobile_app/ @home-assistant/core
/tests/components/mobile_app/ @home-assistant/core
/homeassistant/components/modbus_connection/ @home-assistant/core
/tests/components/modbus_connection/ @home-assistant/core
/homeassistant/components/modem_callerid/ @tkdrob
/tests/components/modem_callerid/ @tkdrob
/homeassistant/components/modern_forms/ @wonderslug
@@ -1347,8 +1374,8 @@ CLAUDE.md @home-assistant/core
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/overseerr/ @joostlek @AmGarera @felixschndr
/tests/components/overseerr/ @joostlek @AmGarera @felixschndr
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
/homeassistant/components/ovo_energy/ @timmo001
@@ -1369,8 +1396,6 @@ CLAUDE.md @home-assistant/core
/tests/components/peco/ @IceBotYT
/homeassistant/components/pegel_online/ @mib1185
/tests/components/pegel_online/ @mib1185
/homeassistant/components/permobil/ @IsakNyberg
/tests/components/permobil/ @IsakNyberg
/homeassistant/components/persistent_notification/ @home-assistant/core
/tests/components/persistent_notification/ @home-assistant/core
/homeassistant/components/pglab/ @pglab-electronics
@@ -1586,6 +1611,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/schlage/ @dknowles2
/tests/components/schlage/ @dknowles2
/homeassistant/components/schluter/ @prairieapps
/homeassistant/components/scorpiontrack/ @Herbertmt978
/tests/components/scorpiontrack/ @Herbertmt978
/homeassistant/components/scrape/ @fabaff @gjohansson-ST
/tests/components/scrape/ @fabaff @gjohansson-ST
/homeassistant/components/screenlogic/ @dieselrabbit @bdraco
@@ -1701,12 +1728,14 @@ CLAUDE.md @home-assistant/core
/tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/soma/ @ratsept
/tests/components/soma/ @ratsept
/homeassistant/components/somfy_mylink/ @sslivins
/tests/components/somfy_mylink/ @sslivins
/homeassistant/components/sonarr/ @ctalkington
/tests/components/sonarr/ @ctalkington
/homeassistant/components/songpal/ @rytilahti @shenxn
/tests/components/songpal/ @rytilahti @shenxn
/homeassistant/components/sonos/ @jjlawren @peterager
/tests/components/sonos/ @jjlawren @peterager
/homeassistant/components/sonos/ @peterager @jjlawren
/tests/components/sonos/ @peterager @jjlawren
/homeassistant/components/soundtouch/ @kroimon
/tests/components/soundtouch/ @kroimon
/homeassistant/components/spaceapi/ @fabaff
@@ -1727,8 +1756,8 @@ CLAUDE.md @home-assistant/core
/tests/components/starline/ @anonym-tsk
/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
/tests/components/statistics/ @ThomDietrich @gjohansson-ST
/homeassistant/components/steam_online/ @tkdrob
/tests/components/steam_online/ @tkdrob
/homeassistant/components/steam_online/ @tr4nt0r @tkdrob
/tests/components/steam_online/ @tr4nt0r @tkdrob
/homeassistant/components/steamist/ @bdraco
/tests/components/steamist/ @bdraco
/homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS
@@ -1955,6 +1984,8 @@ CLAUDE.md @home-assistant/core
/tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/homeassistant/components/vibration/ @home-assistant/core
/tests/components/vibration/ @home-assistant/core
/homeassistant/components/vicare/ @CFenner @lackas
/tests/components/vicare/ @CFenner @lackas
/homeassistant/components/victron_ble/ @rajlaud
@@ -1981,8 +2012,6 @@ CLAUDE.md @home-assistant/core
/tests/components/volumio/ @OnFreund
/homeassistant/components/volvo/ @thomasddn
/tests/components/volvo/ @thomasddn
/homeassistant/components/volvooncall/ @molobrakos @svrooij
/tests/components/volvooncall/ @molobrakos @svrooij
/homeassistant/components/wake_on_lan/ @ntilley905
/tests/components/wake_on_lan/ @ntilley905
/homeassistant/components/wake_word/ @home-assistant/core @synesthesiam
@@ -2042,6 +2071,7 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/window/ @home-assistant/core
/tests/components/window/ @home-assistant/core
/homeassistant/components/wirelesstag/ @sergeymaysak
/tests/components/wirelesstag/ @sergeymaysak
/homeassistant/components/withings/ @joostlek
/tests/components/withings/ @joostlek
/homeassistant/components/wiz/ @sbidy @arturpragacz
@@ -2110,6 +2140,7 @@ CLAUDE.md @home-assistant/core
/tests/components/zeversolar/ @kvanzuijlen @mhuiskes
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zhong_hong/ @crhan
/homeassistant/components/zimi/ @markhannon
/tests/components/zimi/ @markhannon
/homeassistant/components/zinvolt/ @joostlek
+7
View File
@@ -19,3 +19,10 @@ If you want to suggest a new feature for Home Assistant (e.g. new integrations),
## Issue Tracker
If you want to report an issue, please [create an issue](https://github.com/home-assistant/core/issues) on GitHub.
## AI policy
This project follows the [Open Home Foundation AI Policy](AI_POLICY.md). In
short: AI tools are welcome as an aid, but you must fully understand and be
able to explain every change you submit. Contributions made by autonomous
agents are not accepted.
+4
View File
@@ -37,6 +37,10 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
USER vscode
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
# Force "uv run" to use the configured virtual environment for all commands
# avoid using uv run --active for every command
# '.venv' is no longer created
ENV UV_PROJECT_ENVIRONMENT=$VIRTUAL_ENV
RUN --mount=type=bind,source=.python-version,target=.python-version \
uv python install \
&& uv venv $VIRTUAL_ENV
+1 -5
View File
@@ -152,10 +152,6 @@ class AuthManagerFlowManager(
cast(Mapping[str, str], result["data"]),
)
if flow.context.get("credential_only"):
result["result"] = credentials
return result
# multi-factor module cannot enabled for new credential
# which has not linked to a user yet
if auth_provider.support_mfa and not credentials.is_new:
@@ -675,7 +671,7 @@ class AuthManager:
jwt_wrapper.verify_and_decode(
token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"]
)
except jwt.InvalidTokenError:
except jwt.InvalidTokenError, jwt.InvalidKeyError:
return None
if refresh_token is None or not refresh_token.user.is_active:
-1
View File
@@ -26,7 +26,6 @@ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token"
class AuthFlowContext(FlowContext, total=False):
"""Typed context dict for auth flow."""
credential_only: bool
ip_address: IPv4Address | IPv6Address
redirect_uri: str
+11 -3
View File
@@ -5,6 +5,8 @@ from collections.abc import Callable
import voluptuous as vol
from homeassistant.helpers import device_registry as dr
from .const import POLICY_CONTROL, POLICY_EDIT, POLICY_READ, SUBCAT_ALL
from .models import PermissionLookup
from .types import CategoryType, SubCategoryDict, ValueType
@@ -58,12 +60,18 @@ def _lookup_area(
if entity_entry is None or entity_entry.device_id is None:
return None
device_entry = perm_lookup.device_registry.async_get(entity_entry.device_id)
device_registry = perm_lookup.device_registry
device_entry = device_registry.async_get(entity_entry.device_id)
if device_entry is None or device_entry.area_id is None:
if device_entry is None:
return None
return area_dict.get(device_entry.area_id)
area_id = dr.async_get_effective_area_id(device_registry.hass, device_entry)
if area_id is None:
return None
return area_dict.get(area_id)
def _lookup_device(
+1
View File
@@ -264,6 +264,7 @@ DEFAULT_INTEGRATIONS = {
"occupancy",
"power",
"temperature",
"vibration",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "fireavert",
"name": "FireAvert",
"iot_standards": ["zwave"]
}
@@ -0,0 +1,5 @@
{
"domain": "geosphere_austria",
"name": "GeoSphere Austria",
"integrations": ["zamg", "geosphere_austria_warnings"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "midea",
"name": "Midea",
"integrations": ["ccm15", "midea"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "vlc",
"name": "VideoLAN",
"integrations": ["vlc", "vlc_telnet"]
}
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from aioacaia.acaiascale import AcaiaScale
from aioacaia import AcaiaScale
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
+1 -1
View File
@@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, override
from aioacaia.acaiascale import AcaiaScale
from aioacaia import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
@@ -3,8 +3,8 @@
import logging
from typing import Any, override
from aioacaia.discovery import is_new_scale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
from aioacaia.helpers import is_new_scale
import voluptuous as vol
from homeassistant.components.bluetooth import (
@@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from typing import override
from aioacaia.acaiascale import AcaiaScale
from aioacaia import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from homeassistant.components.bluetooth import async_get_scanner
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.18"]
"requirements": ["aioacaia==0.2.1"]
}
+2 -1
View File
@@ -4,8 +4,9 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from aioacaia.acaiascale import AcaiaDeviceState, AcaiaScale
from aioacaia import AcaiaScale
from aioacaia.const import UnitMass as AcaiaUnitOfMass
from aioacaia.scale import AcaiaDeviceState
from homeassistant.components.sensor import (
RestoreSensor,
-1
View File
@@ -6,4 +6,3 @@ LOGGER = logging.getLogger(__package__)
DOMAIN = "acmeda"
ACMEDA_HUB_UPDATE = "acmeda_hub_update_{}"
ACMEDA_ENTITY_REMOVE = "acmeda_entity_remove_{}"
+2 -33
View File
@@ -5,10 +5,9 @@ from typing import override
import aiopulse
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr, entity, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers import device_registry as dr, entity
from .const import ACMEDA_ENTITY_REMOVE, DOMAIN, LOGGER
from .const import DOMAIN, LOGGER
class AcmedaEntity(entity.Entity):
@@ -21,40 +20,11 @@ class AcmedaEntity(entity.Entity):
"""Initialize the roller."""
self.roller = roller
async def async_remove_and_unregister(self) -> None:
"""Unregister from registries and call entity remove function."""
LOGGER.error("Removing %s %s", self.__class__.__name__, self.unique_id)
ent_registry = er.async_get(self.hass)
if self.entity_id in ent_registry.entities:
ent_registry.async_remove(self.entity_id)
dev_registry = dr.async_get(self.hass)
device = dev_registry.async_get_device(identifiers={(DOMAIN, self.unique_id)})
if (
device is not None
and self.registry_entry is not None
and self.registry_entry.config_entry_id is not None
):
dev_registry.async_update_device(
device.id, remove_config_entry_id=self.registry_entry.config_entry_id
)
await self.async_remove(force_remove=True)
@override
async def async_added_to_hass(self) -> None:
"""Entity has been added to hass."""
self.roller.callback_subscribe(self.notify_update)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
ACMEDA_ENTITY_REMOVE.format(self.roller.id),
self.async_remove_and_unregister,
)
)
@override
async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
@@ -85,5 +55,4 @@ class AcmedaEntity(entity.Entity):
identifiers={(DOMAIN, self.unique_id)},
manufacturer="Rollease Acmeda",
name=self.roller.name,
via_device=(DOMAIN, self.roller.hub.id),
)
+3 -1
View File
@@ -48,7 +48,9 @@ async def update_devices(
for api_item in api.values():
# Update Device name
device = dev_registry.async_get_device(identifiers={(DOMAIN, api_item.id)})
device = dev_registry.async_get_device_by_identifier(
(DOMAIN, str(api_item.id)), config_entry.entry_id
)
if device is not None:
dev_registry.async_update_device(
device.id,
+1 -10
View File
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import ACMEDA_ENTITY_REMOVE, ACMEDA_HUB_UPDATE, LOGGER
from .const import ACMEDA_HUB_UPDATE, LOGGER
from .helpers import update_devices
@@ -23,7 +23,6 @@ class PulseHub:
self.config_entry = config_entry
self.hass = hass
self.tasks: list[asyncio.Task[None]] = []
self.current_rollers: dict[int, aiopulse.Roller] = {}
self.cleanup_callbacks: list[Callable[[], None]] = []
@property
@@ -79,11 +78,3 @@ class PulseHub:
async_dispatcher_send(
self.hass, ACMEDA_HUB_UPDATE.format(self.config_entry.entry_id)
)
for unique_id in list(self.current_rollers):
if unique_id not in self.api.rollers:
LOGGER.debug("Notifying remove of %s", unique_id)
self.current_rollers.pop(unique_id)
async_dispatcher_send(
self.hass, ACMEDA_ENTITY_REMOVE.format(unique_id)
)
@@ -6,15 +6,22 @@ from actron_neo_api.models.system import ActronAirSystemInfo
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
ActronAirSystemCoordinator,
)
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.COVER,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
@@ -37,13 +44,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
translation_key="setup_connection_error",
) from err
device_registry = dr.async_get(hass)
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
_LOGGER.debug("Setting up coordinator for system: %s", system.serial)
LOGGER.debug("Setting up coordinator for system: %s", system.serial)
await coordinator.async_config_entry_first_refresh()
system_coordinators[system.serial] = coordinator
# Register the AC system device so zone and peripheral entities can link to
# it as their via device when they are set up.
ac_system = coordinator.data.ac_system
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, system.serial)},
name=ac_system.system_name,
manufacturer="Actron Air",
model_id=ac_system.master_wc_model,
sw_version=ac_system.master_wc_firmware_version,
serial_number=system.serial,
)
entry.runtime_data = ActronAirRuntimeData(
api=api,
system_coordinators=system_coordinators,
@@ -0,0 +1,83 @@
"""Binary sensor platform for Actron Air integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from actron_neo_api import ActronAirStatus
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ActronAirBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Actron Air binary sensor entity."""
value_fn: Callable[[ActronAirStatus], bool]
BINARY_SENSORS: tuple[ActronAirBinarySensorEntityDescription, ...] = (
ActronAirBinarySensorEntityDescription(
key="clean_filter",
translation_key="clean_filter",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.clean_filter,
),
ActronAirBinarySensorEntityDescription(
key="defrost_mode",
translation_key="defrost_mode",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.defrost_mode,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air binary sensor entities."""
system_coordinators = entry.runtime_data.system_coordinators
async_add_entities(
ActronAirBinarySensor(coordinator, description)
for coordinator in system_coordinators.values()
for description in BINARY_SENSORS
)
class ActronAirBinarySensor(ActronAirAcEntity, BinarySensorEntity):
"""Representation of an Actron Air binary sensor."""
entity_description: ActronAirBinarySensorEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
description: ActronAirBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
@property
@override
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -226,12 +226,6 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
"""Return the maximum temperature that can be set."""
return self._zone.max_temp
@property
def _zone(self) -> ActronAirZone:
"""Get the current zone data from the coordinator."""
status = self.coordinator.data
return status.zones[self._zone_id]
@property
@override
def hvac_mode(self) -> HVACMode | None:
@@ -15,7 +15,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -36,12 +36,12 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
if self._api is None:
_LOGGER.debug("Initiating device authorization")
LOGGER.debug("Initiating device authorization")
self._api = ActronAirAPI()
try:
device_code_response = await self._api.request_device_code()
except ActronAirAuthError as err:
_LOGGER.error("OAuth2 flow failed: %s", err)
LOGGER.error("OAuth2 flow failed: %s", err)
return self.async_abort(reason="oauth2_error")
self._device_code = device_code_response.device_code
@@ -53,21 +53,21 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
"""Wait for the user to authorize the device."""
assert self._api is not None
assert self._device_code is not None
_LOGGER.debug("Waiting for device authorization")
LOGGER.debug("Waiting for device authorization")
try:
await self._api.poll_for_token(self._device_code)
_LOGGER.debug("Authorization successful")
LOGGER.debug("Authorization successful")
except ActronAirAuthError as ex:
_LOGGER.exception("Error while waiting for device authorization")
LOGGER.exception("Error while waiting for device authorization")
raise CannotConnect from ex
_LOGGER.debug("Checking login task")
LOGGER.debug("Checking login task")
if self.login_task is None:
_LOGGER.debug("Creating task for device authorization")
LOGGER.debug("Creating task for device authorization")
self.login_task = self.hass.async_create_task(_wait_for_authorization())
if self.login_task.done():
_LOGGER.debug("Login task is done, checking results")
LOGGER.debug("Login task is done, checking results")
if exception := self.login_task.exception():
if isinstance(exception, CannotConnect):
return self.async_show_progress_done(
@@ -91,13 +91,13 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the finalization of login."""
_LOGGER.debug("Finalizing authorization")
LOGGER.debug("Finalizing authorization")
assert self._api is not None
try:
user_data = await self._api.get_user_info()
except ActronAirAuthError as err:
_LOGGER.error("Error getting user info: %s", err)
LOGGER.error("Error getting user info: %s", err)
return self.async_abort(reason="oauth2_error")
unique_id = user_data.sub
+1 -1
View File
@@ -2,5 +2,5 @@
import logging
_LOGGER = logging.getLogger(__package__)
LOGGER = logging.getLogger(__package__)
DOMAIN = "actron_air"
@@ -8,6 +8,7 @@ from actron_neo_api import (
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
ActronAirPeripheral,
ActronAirStatus,
)
from actron_neo_api.models.system import ActronAirSystemInfo
@@ -18,7 +19,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
SCAN_INTERVAL = timedelta(seconds=30)
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
@@ -40,6 +41,8 @@ type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
"""System coordinator for Actron Air integration."""
config_entry: ActronAirConfigEntry
def __init__(
self,
hass: HomeAssistant,
@@ -50,7 +53,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
LOGGER,
name="Actron Air Status",
update_interval=SCAN_INTERVAL,
config_entry=entry,
@@ -59,6 +62,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
self.serial_number = system.serial
self.api = api
self.status = self.api.state_manager.get_status(self.serial_number)
self.peripherals: dict[str, ActronAirPeripheral] = {}
self.last_seen = dt_util.utcnow()
@override
@@ -86,6 +90,9 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
translation_placeholders={"error": "Status not available"},
)
self.status = status
self.peripherals = {
peripheral.serial_number: peripheral for peripheral in status.peripherals
}
self.last_seen = dt_util.utcnow()
return self.status
@@ -0,0 +1,64 @@
"""Cover platform for Actron Air integration."""
from typing import override
from actron_neo_api import ActronAirZone
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirZoneEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air cover entities."""
system_coordinators = entry.runtime_data.system_coordinators
async_add_entities(
ActronAirZoneDamper(coordinator, zone)
for coordinator in system_coordinators.values()
for zone in coordinator.data.remote_zone_info
if zone.exists
)
class ActronAirZoneDamper(ActronAirZoneEntity, CoverEntity):
"""Representation of the damper of an Actron Air zone."""
_attr_device_class = CoverDeviceClass.DAMPER
_attr_translation_key = "damper"
# The damper is read-only; CoverEntity infers movement features when unset.
_attr_supported_features = CoverEntityFeature(0)
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
zone: ActronAirZone,
) -> None:
"""Initialize the damper."""
super().__init__(coordinator, zone)
self._attr_unique_id = f"{self._zone_identifier}_damper"
@property
@override
def current_cover_position(self) -> int:
"""Return how far the damper is open, from 0 to 100."""
return round(self._zone.zone_position)
@property
@override
def is_closed(self) -> bool:
"""Return True if the damper is fully closed."""
return self.current_cover_position == 0
+51 -2
View File
@@ -4,9 +4,10 @@ from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate, override
from actron_neo_api import ActronAirAPIError, ActronAirZone
from actron_neo_api import ActronAirAPIError, ActronAirPeripheral, ActronAirZone
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -90,5 +91,53 @@ class ActronAirZoneEntity(ActronAirEntity):
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, self._serial_number),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, self._serial_number),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@property
def _zone(self) -> ActronAirZone:
"""Get the current zone data from the coordinator."""
return self.coordinator.data.zones[self._zone_id]
class ActronAirPeripheralEntity(ActronAirEntity):
"""Base class for Actron Air peripheral entities."""
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
peripheral: ActronAirPeripheral,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._peripheral_serial = peripheral.serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, peripheral.serial_number)},
name=f"Sensor {peripheral.serial_number}",
manufacturer="Actron Air",
model=peripheral.device_type,
serial_number=peripheral.serial_number,
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, self._serial_number),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@property
@override
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and self._peripheral_serial in self.coordinator.peripherals
)
@property
def _peripheral(self) -> ActronAirPeripheral:
"""Get the current peripheral data from the coordinator."""
return self.coordinator.peripherals[self._peripheral_serial]
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.12"]
"requirements": ["actron-neo-api==0.5.13"]
}
@@ -61,8 +61,8 @@ rules:
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
@@ -0,0 +1,213 @@
"""Sensor platform for Actron Air integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from actron_neo_api import ActronAirStatus
from actron_neo_api.models.zone import ActronAirPeripheral
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
EntityCategory,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirPeripheralEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ActronAirSensorEntityDescription(SensorEntityDescription):
"""Describes Actron Air sensor entity."""
value_fn: Callable[[ActronAirStatus], str | float | int | None]
@dataclass(frozen=True, kw_only=True)
class ActronAirPeripheralSensorEntityDescription(SensorEntityDescription):
"""Describes Actron Air peripheral sensor entity."""
value_fn: Callable[[ActronAirPeripheral], float | None]
SENSORS: tuple[ActronAirSensorEntityDescription, ...] = (
ActronAirSensorEntityDescription(
key="compressor_mode",
translation_key="compressor_mode",
device_class=SensorDeviceClass.ENUM,
options=["off", "cool", "heat"],
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda status: status.compressor_mode.lower() or None,
),
ActronAirSensorEntityDescription(
key="compressor_chasing_temperature",
translation_key="compressor_chasing_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda status: status.compressor_chasing_temperature,
),
ActronAirSensorEntityDescription(
key="compressor_live_temperature",
translation_key="compressor_live_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda status: status.compressor_live_temperature,
),
ActronAirSensorEntityDescription(
key="compressor_power",
translation_key="compressor_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda status: status.compressor_power,
),
ActronAirSensorEntityDescription(
key="compressor_speed",
translation_key="compressor_speed",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda status: status.compressor_speed,
),
ActronAirSensorEntityDescription(
key="compressor_capacity",
translation_key="compressor_capacity",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda status: status.live_aircon.compressor_capacity,
),
ActronAirSensorEntityDescription(
key="fan_speed",
translation_key="fan_speed",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda status: status.live_aircon.fan_rpm,
),
ActronAirSensorEntityDescription(
key="outdoor_temperature",
translation_key="outdoor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda status: status.outdoor_temperature,
),
)
PERIPHERAL_SENSORS: tuple[ActronAirPeripheralSensorEntityDescription, ...] = (
ActronAirPeripheralSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda peripheral: peripheral.temperature,
),
ActronAirPeripheralSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda peripheral: peripheral.humidity,
),
ActronAirPeripheralSensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda peripheral: peripheral.battery_level,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air sensor entities."""
system_coordinators = entry.runtime_data.system_coordinators
entities: list[SensorEntity] = []
for coordinator in system_coordinators.values():
entities.extend(
ActronAirSensor(coordinator, description) for description in SENSORS
)
entities.extend(
ActronAirPeripheralSensor(coordinator, peripheral, description)
for peripheral in coordinator.data.peripherals
for description in PERIPHERAL_SENSORS
)
async_add_entities(entities)
class ActronAirSensor(ActronAirAcEntity, SensorEntity):
"""Representation of an Actron Air sensor."""
entity_description: ActronAirSensorEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
description: ActronAirSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
@property
@override
def native_value(self) -> str | float | int | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
class ActronAirPeripheralSensor(ActronAirPeripheralEntity, SensorEntity):
"""Representation of an Actron Air peripheral sensor."""
entity_description: ActronAirPeripheralSensorEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
peripheral: ActronAirPeripheral,
description: ActronAirPeripheralSensorEntityDescription,
) -> None:
"""Initialize the peripheral sensor."""
super().__init__(coordinator, peripheral)
self.entity_description = description
self._attr_unique_id = f"{peripheral.serial_number}_{description.key}"
@property
@override
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._peripheral)
@@ -38,6 +38,50 @@
}
},
"entity": {
"binary_sensor": {
"clean_filter": {
"name": "Clean filter"
},
"defrost_mode": {
"name": "Defrost mode"
}
},
"cover": {
"damper": {
"name": "Damper"
}
},
"sensor": {
"compressor_capacity": {
"name": "Compressor capacity"
},
"compressor_chasing_temperature": {
"name": "Compressor chasing temperature"
},
"compressor_live_temperature": {
"name": "Compressor live temperature"
},
"compressor_mode": {
"name": "Compressor mode",
"state": {
"cool": "Cool",
"heat": "Heat",
"off": "[%key:common::state::off%]"
}
},
"compressor_power": {
"name": "Compressor power"
},
"compressor_speed": {
"name": "Compressor speed"
},
"fan_speed": {
"name": "Fan speed"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
}
},
"switch": {
"away_mode": {
"name": "Away mode"
@@ -4,7 +4,7 @@ from advantage_air import advantage_air
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
@@ -50,6 +50,18 @@ async def async_setup_entry(
entry.runtime_data = coordinator
# Register the system device so child devices can resolve it as their
# via_device parent regardless of platform setup order.
system = coordinator.data["system"]
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, system["rid"])},
manufacturer="Advantage Air",
model=system["sysType"],
name=system["name"],
sw_version=system["myAppRev"],
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -5,6 +5,7 @@ from typing import Any
from advantage_air import ApiError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -49,7 +50,11 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
self._attr_unique_id += f"-{ac_key}"
self._attr_device_info = DeviceInfo(
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, self.coordinator.data["system"]["rid"]),
config_entry_id=self.coordinator.config_entry.entry_id,
),
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Advantage Air",
model=self.coordinator.data["system"]["sysType"],
@@ -105,7 +110,11 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
self._attr_unique_id += f"-{self._id}"
self._attr_device_info = DeviceInfo(
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, self.coordinator.data["system"]["rid"]),
config_entry_id=self.coordinator.config_entry.entry_id,
),
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Advantage Air",
model="MyPlace",
@@ -4,6 +4,7 @@ from typing import Any, override
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -55,7 +56,11 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
self._attr_unique_id += f"-{self._id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, self.coordinator.data["system"]["rid"]),
config_entry_id=self.coordinator.config_entry.entry_id,
),
manufacturer="Advantage Air",
model=light.get("moduleType"),
name=light["name"],
+2 -2
View File
@@ -181,8 +181,8 @@ class AITaskPreferences:
def async_set_preferences(
self,
*,
gen_data_entity_id: str | None | UndefinedType = UNDEFINED,
gen_image_entity_id: str | None | UndefinedType = UNDEFINED,
gen_data_entity_id: str | UndefinedType | None = UNDEFINED,
gen_image_entity_id: str | UndefinedType | None = UNDEFINED,
) -> None:
"""Set the preferences."""
changed = False
@@ -48,5 +48,6 @@
},
"name": "Generate image"
}
}
},
"title": "AI task"
}
+3 -3
View File
@@ -1,7 +1,7 @@
"""AI tasks to be handled by agents."""
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
import io
import mimetypes
from pathlib import Path
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import llm
from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session
from homeassistant.util import RE_SANITIZE_FILENAME, slugify
from homeassistant.util import RE_SANITIZE_FILENAME, dt as dt_util, slugify
from .const import (
DATA_COMPONENT,
@@ -210,7 +210,7 @@ async def async_generate_image(
source = hass.data[DATA_MEDIA_SOURCE]
current_time = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
current_time = dt_util.now()
ext = mimetypes.guess_extension(task_result.mime_type, False) or ".png"
sanitized_task_name = RE_SANITIZE_FILENAME.sub("", slugify(task_name))
@@ -163,6 +163,4 @@ class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]):
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id
)
device_reg.async_remove_device(device.id)
+6 -1
View File
@@ -3,9 +3,14 @@
"name": "AiDot",
"codeowners": ["@s1eedz", "@HongBryan"],
"config_flow": true,
"dhcp": [
{
"hostname": "aidot"
}
],
"documentation": "https://www.home-assistant.io/integrations/aidot",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-aidot==0.3.53"]
"requirements": ["python-aidot==0.3.56"]
}
@@ -49,7 +49,7 @@ rules:
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
+2 -1
View File
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -75,8 +75,8 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
) from error
if measures.firmware_version != self._current_version:
device_registry = dr.async_get(self.hass)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, self.serial_number)}
device_entry = device_registry.async_get_device_by_identifier(
(DOMAIN, self.serial_number), self.config_entry.entry_id
)
assert device_entry
device_registry.async_update_device(
@@ -112,7 +112,7 @@ MEASUREMENT_SENSOR_TYPES: tuple[AirGradientMeasurementSensorEntityDescription, .
AirGradientMeasurementSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.rco2,
),
@@ -217,7 +217,7 @@ CONFIG_DISPLAY_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...
translation_key="display_brightness",
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: config.led_bar_brightness,
value_fn=lambda config: config.display_brightness,
),
)
+5 -2
View File
@@ -43,8 +43,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> boo
str(longitude),
),
):
device_entry = device_registry.async_get_device(identifiers={old_ids}) # type: ignore[arg-type]
if device_entry and entry.entry_id in device_entry.config_entries:
device_entry = device_registry.async_get_device_by_identifier(
old_ids, # type: ignore[arg-type]
entry.entry_id,
)
if device_entry:
new_ids = (DOMAIN, f"{latitude}-{longitude}")
device_registry.async_update_device(
device_entry.id, new_identifiers={new_ids}
+8 -9
View File
@@ -27,16 +27,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
# Station Radius is a user-configurable option
distance = entry.options[CONF_RADIUS]
# Reports are published hourly but update twice per hour
update_interval = datetime.timedelta(minutes=30)
# Setup the Coordinator
session = async_get_clientsession(hass)
coordinator = AirNowDataUpdateCoordinator(
hass, entry, session, api_key, latitude, longitude, distance, update_interval
hass, entry, session, api_key, latitude, longitude, update_interval
)
# Sync with Coordinator
@@ -68,13 +65,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
new_options = {CONF_RADIUS: entry.data[CONF_RADIUS]}
new_data = entry.data.copy()
del new_data[CONF_RADIUS]
if entry.version < 3:
# The 2026 AirNow API dropped the distance parameter, so the radius
# option no longer affects lookups. Strip it from both older layouts:
# version 1 kept it in data, version 2 in options.
new_data = {k: v for k, v in entry.data.items() if k != CONF_RADIUS}
new_options = {k: v for k, v in entry.options.items() if k != CONF_RADIUS}
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options, version=2
entry, data=new_data, options=new_options, version=3
)
_LOGGER.info("Migration to version %s successful", entry.version)
+5 -47
View File
@@ -7,14 +7,9 @@ from pyairnow import WebServiceAPI
from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.core import HomeAssistant, callback
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -38,11 +33,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
lat = data[CONF_LATITUDE]
lng = data[CONF_LONGITUDE]
distance = data[CONF_RADIUS]
# Check that the provided latitude/longitude provide a response
try:
test_data = await client.observations.latLong(lat, lng, distance=distance)
test_data = await client.observations.latLong(lat, lng)
except InvalidKeyError as exc:
raise InvalidAuth from exc
@@ -61,7 +55,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for AirNow."""
VERSION = 2
VERSION = 3
@override
async def async_step_user(
@@ -91,14 +85,12 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
else:
# Create Entry
radius = user_input.pop(CONF_RADIUS)
return self.async_create_entry(
title=(
f"AirNow Sensor at {user_input[CONF_LATITUDE]},"
f" {user_input[CONF_LONGITUDE]}"
),
data=user_input,
options={CONF_RADIUS: radius},
)
return self.async_show_form(
@@ -112,46 +104,12 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Optional(CONF_RADIUS, default=150): vol.All(
int, vol.Range(min=5)
),
}
),
description_placeholders={"api_key_url": _API_KEY_URL},
errors=errors,
)
@staticmethod
@callback
@override
def async_get_options_flow(
config_entry: ConfigEntry,
) -> AirNowOptionsFlowHandler:
"""Return the options flow."""
return AirNowOptionsFlowHandler()
class AirNowOptionsFlowHandler(OptionsFlowWithReload):
"""Handle an options flow for AirNow."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
options_schema = vol.Schema(
{vol.Optional(CONF_RADIUS): vol.All(int, vol.Range(min=5))}
)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
options_schema, self.config_entry.options
),
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
@@ -51,13 +51,11 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
api_key: str,
latitude: float,
longitude: float,
distance: int,
update_interval: timedelta,
) -> None:
"""Initialize."""
self.latitude = latitude
self.longitude = longitude
self.distance = distance
self.airnow = WebServiceAPI(api_key, session=session)
@@ -77,7 +75,6 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
obs = await self.airnow.observations.latLong(
self.latitude,
self.longitude,
distance=self.distance,
)
except (AirNowError, ClientConnectorError, InvalidJsonError) as error:
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyairnow"],
"requirements": ["pyairnow==1.3.1"]
"requirements": ["pyairnow==1.4.0"]
}
+3 -17
View File
@@ -6,7 +6,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_location": "No results found for that location, try changing the location or station radius.",
"invalid_location": "No results found for that location, try changing the location.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
@@ -14,14 +14,12 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station radius (miles; optional)"
"longitude": "[%key:common::config_flow::data::longitude%]"
},
"data_description": {
"api_key": "To generate an API key, go to {api_key_url}.",
"latitude": "The latitude of your location.",
"longitude": "The longitude of your location.",
"radius": "The radius in miles around your location to search for reporting stations."
"longitude": "The longitude of your location."
},
"description": "To generate an API key, go to {api_key_url}."
}
@@ -40,17 +38,5 @@
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"radius": "Station radius (miles)"
},
"data_description": {
"radius": "The radius in miles around your location to search for reporting stations."
}
}
}
}
}
+1 -15
View File
@@ -4,11 +4,7 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, override
from pyairobotrest.exceptions import (
AirobotConnectionError,
AirobotError,
AirobotTimeoutError,
)
from pyairobotrest.exceptions import AirobotError
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -32,7 +28,6 @@ class AirobotButtonEntityDescription(ButtonEntityDescription):
"""Describes Airobot button entity."""
press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
ignore_connection_errors: bool = False
BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
@@ -41,7 +36,6 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
ignore_connection_errors=True,
),
AirobotButtonEntityDescription(
key="recalibrate_co2",
@@ -86,14 +80,6 @@ class AirobotButton(AirobotEntity, ButtonEntity):
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
except (AirobotConnectionError, AirobotTimeoutError) as err:
if not self.entity_description.ignore_connection_errors:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="button_press_failed",
translation_placeholders={"button": self.entity_description.key},
) from err
# Connection errors during reboot are expected as device restarts
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -13,5 +13,5 @@
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "platinum",
"requirements": ["pyairobotrest==0.3.0"]
"requirements": ["pyairobotrest==0.4.0"]
}
+2 -2
View File
@@ -184,8 +184,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> b
mac_adress = dr.format_mac(entry.unique_id)
device_registry = dr.async_get(hass)
if device_entry := device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac_adress)}
if device_entry := device_registry.async_get_device_by_connection(
(dr.CONNECTION_NETWORK_MAC, mac_adress), entry.entry_id
):
old_device_id = next(
(
@@ -169,7 +169,9 @@ PARALLEL_UPDATES = 0
@callback
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
def async_migrate(
hass: HomeAssistant, entry_id: str, address: str, sensor_name: str
) -> None:
"""Migrate entities to new unique ids (with BLE Address)."""
ent_reg = er.async_get(hass)
unique_id_trailer = f"_{sensor_name}"
@@ -179,8 +181,8 @@ def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
return
dev_reg = dr.async_get(hass)
if not (
device := dev_reg.async_get_device(
connections={(CONNECTION_BLUETOOTH, address)}
device := dev_reg.async_get_device_by_connection(
(CONNECTION_BLUETOOTH, address), entry_id
)
):
return
@@ -221,7 +223,7 @@ async def async_setup_entry(
sensor_value,
)
continue
async_migrate(hass, coordinator.data.address, sensor_type)
async_migrate(hass, entry.entry_id, coordinator.data.address, sensor_type)
entities.append(
AirthingsSensor(
coordinator, coordinator.data, SENSORS_MAPPING_TEMPLATE[sensor_type]
+7 -6
View File
@@ -9,14 +9,13 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
ATTR_STATE,
CONF_COUNTRY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_SHOW_ON_MAP,
CONF_STATE,
EntityStateAttribute,
UnitOfDensity,
UnitOfRatio,
)
@@ -191,12 +190,14 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity):
)
if self.coordinator.config_entry.options[CONF_SHOW_ON_MAP]:
self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude
self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude
self._attr_extra_state_attributes[EntityStateAttribute.LATITUDE] = latitude
self._attr_extra_state_attributes[EntityStateAttribute.LONGITUDE] = (
longitude
)
self._attr_extra_state_attributes.pop("lati", None)
self._attr_extra_state_attributes.pop("long", None)
else:
self._attr_extra_state_attributes["lati"] = latitude
self._attr_extra_state_attributes["long"] = longitude
self._attr_extra_state_attributes.pop(ATTR_LATITUDE, None)
self._attr_extra_state_attributes.pop(ATTR_LONGITUDE, None)
self._attr_extra_state_attributes.pop(EntityStateAttribute.LATITUDE, None)
self._attr_extra_state_attributes.pop(EntityStateAttribute.LONGITUDE, None)
+55 -12
View File
@@ -6,8 +6,12 @@ from typing import Any
from aioairzone.const import (
AZD_FIRMWARE,
AZD_FULL_NAME,
AZD_HOT_WATER,
AZD_ID,
AZD_MAC,
AZD_MODEL,
AZD_NAME,
AZD_SYSTEMS,
AZD_WEBSERVER,
DEFAULT_SYSTEM_ID,
)
@@ -93,19 +97,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
device_registry = dr.async_get(hass)
ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER)
if ws_data is not None:
mac = ws_data.get(AZD_MAC, "")
@callback
def _async_register_devices() -> None:
"""Register the WebServer, System, and DHW via_device parents.
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={(DOMAIN, f"{entry.entry_id}_ws")},
manufacturer=MANUFACTURER,
model=ws_data.get(AZD_MODEL),
name=ws_data.get(AZD_FULL_NAME),
sw_version=ws_data.get(AZD_FIRMWARE),
)
The WebServer, Systems, and DHW can appear on later coordinator
updates, so this runs on every update (before the platform
listeners) to keep the via_device parents registered before their
child entities are added.
"""
ws_device_id: str | None = None
ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER)
if ws_data is not None:
mac = ws_data.get(AZD_MAC, "")
ws_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={(DOMAIN, f"{entry.entry_id}_ws")},
manufacturer=MANUFACTURER,
model=ws_data.get(AZD_MODEL),
name=ws_data.get(AZD_FULL_NAME),
sw_version=ws_data.get(AZD_FIRMWARE),
)
ws_device_id = ws_device.id
for system_data in coordinator.data.get(AZD_SYSTEMS, {}).values():
system_id = system_data[AZD_ID]
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{entry.entry_id}_{system_id}")},
manufacturer=MANUFACTURER,
model=system_data.get(AZD_MODEL),
name=f"System {system_id}",
sw_version=system_data.get(AZD_FIRMWARE),
via_device_id=ws_device_id,
)
dhw_data: dict[str, Any] | None = coordinator.data.get(AZD_HOT_WATER)
if dhw_data is not None:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{entry.entry_id}_dhw")},
manufacturer=MANUFACTURER,
model="DHW",
name=dhw_data.get(AZD_NAME),
via_device_id=ws_device_id,
)
# Register the parents before forwarding platforms, and keep them registered
# as new devices appear so child entities can resolve their via_device_id.
_async_register_devices()
entry.async_on_unload(coordinator.async_add_listener(_async_register_devices))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+19 -3
View File
@@ -67,7 +67,13 @@ class AirzoneSystemEntity(AirzoneEntity):
sw_version=self.get_airzone_value(AZD_FIRMWARE),
)
if AZD_WEBSERVER in self.coordinator.data:
self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws")
self._attr_device_info["via_device_id"] = (
dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, f"{entry.entry_id}_ws"),
config_entry_id=entry.entry_id,
)
)
self._attr_unique_id = entry.unique_id or entry.entry_id
@property
@@ -120,7 +126,13 @@ class AirzoneHotWaterEntity(AirzoneEntity):
name=self.get_airzone_value(AZD_NAME),
)
if AZD_WEBSERVER in self.coordinator.data:
self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws")
self._attr_device_info["via_device_id"] = (
dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, f"{entry.entry_id}_ws"),
config_entry_id=entry.entry_id,
)
)
self._attr_unique_id = entry.unique_id or entry.entry_id
@override
@@ -195,7 +207,11 @@ class AirzoneZoneEntity(AirzoneEntity):
model=self.get_airzone_value(AZD_THERMOSTAT_MODEL),
name=zone_data[AZD_NAME],
sw_version=self.get_airzone_value(AZD_THERMOSTAT_FW),
via_device=(DOMAIN, f"{entry.entry_id}_{self.system_id}"),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, f"{entry.entry_id}_{self.system_id}"),
config_entry_id=entry.entry_id,
),
)
self._attr_unique_id = entry.unique_id or entry.entry_id
@@ -2,11 +2,20 @@
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.common import ConnectionOptions
from aioairzone_cloud.const import (
AZD_FIRMWARE,
AZD_MODEL,
AZD_NAME,
AZD_SYSTEMS,
AZD_WEBSERVER,
AZD_WEBSERVERS,
)
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client, device_registry as dr
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -42,11 +51,53 @@ async def async_setup_entry(
entry.runtime_data = coordinator
_async_register_devices(hass, entry, coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@callback
def _async_register_devices(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
coordinator: AirzoneUpdateCoordinator,
) -> None:
"""Register WebServer and System devices referenced as via_device parents.
Child devices resolve their via_device_id at add time, so the parents must
already exist regardless of which platform creates their own entities.
"""
device_registry = dr.async_get(hass)
for ws_id, ws_data in coordinator.data.get(AZD_WEBSERVERS, {}).items():
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, ws_id)},
identifiers={(DOMAIN, ws_id)},
manufacturer=MANUFACTURER,
model="WebServer",
name=ws_data[AZD_NAME],
sw_version=ws_data[AZD_FIRMWARE],
)
for system_id, system_data in coordinator.data.get(AZD_SYSTEMS, {}).items():
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, system_id)},
manufacturer=MANUFACTURER,
model=system_data.get(AZD_MODEL),
name=system_data[AZD_NAME],
sw_version=system_data.get(AZD_FIRMWARE),
via_device_id=dr.async_get_device_id_by_identifier(
hass,
(DOMAIN, system_data[AZD_WEBSERVER]),
config_entry_id=entry.entry_id,
),
)
async def async_unload_entry(
hass: HomeAssistant, entry: AirzoneCloudConfigEntry
) -> bool:
@@ -73,7 +73,11 @@ class AirzoneAidooEntity(AirzoneEntity):
manufacturer=MANUFACTURER,
model=aidoo_data[AZD_MODEL],
name=aidoo_data[AZD_NAME],
via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, aidoo_data[AZD_WEBSERVER]),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@override
@@ -164,7 +168,11 @@ class AirzoneHotWaterEntity(AirzoneEntity):
manufacturer=MANUFACTURER,
model="Hot Water",
name=dhw_data[AZD_NAME],
via_device=(DOMAIN, dhw_data[AZD_WEBSERVER]),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, dhw_data[AZD_WEBSERVER]),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@override
@@ -257,7 +265,11 @@ class AirzoneSystemEntity(AirzoneEntity):
model=system_data.get(AZD_MODEL),
manufacturer=MANUFACTURER,
name=system_data[AZD_NAME],
via_device=(DOMAIN, system_data[AZD_WEBSERVER]),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, system_data[AZD_WEBSERVER]),
config_entry_id=coordinator.config_entry.entry_id,
),
sw_version=system_data.get(AZD_FIRMWARE),
)
@@ -322,7 +334,11 @@ class AirzoneZoneEntity(AirzoneEntity):
model=zone_data.get(AZD_THERMOSTAT_MODEL),
manufacturer=MANUFACTURER,
name=zone_data[AZD_NAME],
via_device=(DOMAIN, self.system_id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, self.system_id),
config_entry_id=coordinator.config_entry.entry_id,
),
sw_version=zone_data.get(AZD_THERMOSTAT_FW),
)
@@ -111,6 +111,4 @@ def remove_stale_devices(
break
if device_id and device_id not in all_device_ids:
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)
device_registry.async_remove_device(device_entry.id)
@@ -41,7 +41,6 @@ class AsyncConfigEntryAuth(Auth):
@override
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])
+73 -64
View File
@@ -5,19 +5,15 @@ import logging
from typing import Any, override
from homeassistant.components import (
button,
climate,
cover,
fan,
humidifier,
image_processing,
input_button,
input_number,
light,
media_player,
number,
remote,
timer,
vacuum,
valve,
water_heater,
@@ -27,8 +23,21 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.components.climate import HVACMode
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN
from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.lock import LockState
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.const import (
ATTR_CODE_FORMAT,
ATTR_SUPPORTED_FEATURES,
@@ -437,19 +446,19 @@ class AlexaPowerController(AlexaCapability):
if name != "powerState":
raise UnsupportedProperty(name)
if self.entity.domain == climate.DOMAIN:
if self.entity.domain == CLIMATE_DOMAIN:
is_on = self.entity.state != climate.HVACMode.OFF
elif self.entity.domain == fan.DOMAIN:
elif self.entity.domain == FAN_DOMAIN:
is_on = self.entity.state == fan.STATE_ON
elif self.entity.domain == humidifier.DOMAIN:
elif self.entity.domain == HUMIDIFIER_DOMAIN:
is_on = self.entity.state == humidifier.STATE_ON
elif self.entity.domain == remote.DOMAIN:
elif self.entity.domain == REMOTE_DOMAIN:
is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN)
elif self.entity.domain == vacuum.DOMAIN:
elif self.entity.domain == VACUUM_DOMAIN:
is_on = self.entity.state == vacuum.VacuumActivity.CLEANING
elif self.entity.domain == timer.DOMAIN:
elif self.entity.domain == TIMER_DOMAIN:
is_on = self.entity.state != STATE_IDLE
elif self.entity.domain == water_heater.DOMAIN:
elif self.entity.domain == WATER_HEATER_DOMAIN:
is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN)
else:
is_on = self.entity.state != STATE_OFF
@@ -867,7 +876,7 @@ class AlexaPlaybackController(AlexaCapability):
operations: dict[
cover.CoverEntityFeature | media_player.MediaPlayerEntityFeature, str
]
if self.entity.domain == cover.DOMAIN:
if self.entity.domain == COVER_DOMAIN:
operations = {cover.CoverEntityFeature.STOP: "Stop"}
else:
operations = {
@@ -1002,10 +1011,10 @@ class AlexaTemperatureSensor(AlexaCapability):
ATTR_UNIT_OF_MEASUREMENT, self.hass.config.units.temperature_unit
)
temp: str | None = self.entity.state
if self.entity.domain == climate.DOMAIN:
if self.entity.domain == CLIMATE_DOMAIN:
unit = self.hass.config.units.temperature_unit
temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE)
elif self.entity.domain == water_heater.DOMAIN:
elif self.entity.domain == WATER_HEATER_DOMAIN:
unit = self.hass.config.units.temperature_unit
temp = self.entity.attributes.get(water_heater.ATTR_CURRENT_TEMPERATURE)
@@ -1189,14 +1198,14 @@ class AlexaThermostatController(AlexaCapability):
"""Return what properties this entity supports."""
properties = [{"name": "thermostatMode"}]
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if self.entity.domain == climate.DOMAIN:
if self.entity.domain == CLIMATE_DOMAIN:
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
properties.append({"name": "lowerSetpoint"})
properties.append({"name": "upperSetpoint"})
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE:
properties.append({"name": "targetSetpoint"})
elif (
self.entity.domain == water_heater.DOMAIN
self.entity.domain == WATER_HEATER_DOMAIN
and supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE
):
properties.append({"name": "targetSetpoint"})
@@ -1219,7 +1228,7 @@ class AlexaThermostatController(AlexaCapability):
return None
if name == "thermostatMode":
if self.entity.domain == water_heater.DOMAIN:
if self.entity.domain == WATER_HEATER_DOMAIN:
return None
preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE)
@@ -1273,7 +1282,7 @@ class AlexaThermostatController(AlexaCapability):
ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM.
Water heater devices do not return thermostat modes.
"""
if self.entity.domain == water_heater.DOMAIN:
if self.entity.domain == WATER_HEATER_DOMAIN:
return None
hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []
@@ -1508,19 +1517,19 @@ class AlexaModeController(AlexaCapability):
raise UnsupportedProperty(name)
# Fan Direction
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_DIRECTION}":
mode = self.entity.attributes.get(fan.ATTR_DIRECTION, None)
if mode in (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE, STATE_UNKNOWN):
return f"{fan.ATTR_DIRECTION}.{mode}"
# Fan preset_mode
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}":
mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None)
if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()):
return f"{fan.ATTR_PRESET_MODE}.{mode}"
# Humidifier mode
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}":
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}":
mode = self.entity.attributes.get(humidifier.ATTR_MODE)
modes: list[str] = (
self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) or []
@@ -1529,13 +1538,13 @@ class AlexaModeController(AlexaCapability):
return f"{humidifier.ATTR_MODE}.{mode}"
# Remote Activity
if self.instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
if self.instance == f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}":
activity = self.entity.attributes.get(remote.ATTR_CURRENT_ACTIVITY, None)
if activity in self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST, []):
return f"{remote.ATTR_ACTIVITY}.{activity}"
# Water heater operation mode
if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
if self.instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = self.entity.attributes.get(
water_heater.ATTR_OPERATION_MODE
)
@@ -1546,7 +1555,7 @@ class AlexaModeController(AlexaCapability):
return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}"
# Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
# Return state instead of position when using ModeController.
mode = self.entity.state
if mode in (
@@ -1559,7 +1568,7 @@ class AlexaModeController(AlexaCapability):
return f"{cover.ATTR_POSITION}.{mode}"
# Valve position state
if self.instance == f"{valve.DOMAIN}.state":
if self.instance == f"{VALVE_DOMAIN}.state":
# Return state instead of position when using ModeController.
state = self.entity.state
if state in (
@@ -1586,7 +1595,7 @@ class AlexaModeController(AlexaCapability):
"""Return capabilityResources object."""
# Fan Direction Resource
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_DIRECTION}":
self._resource = AlexaModeResource(
[AlexaGlobalCatalog.SETTING_DIRECTION], False
)
@@ -1599,7 +1608,7 @@ class AlexaModeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Fan preset_mode
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}":
self._resource = AlexaModeResource(
[AlexaGlobalCatalog.SETTING_PRESET], False
)
@@ -1617,7 +1626,7 @@ class AlexaModeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Humidifier modes
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}":
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}":
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
modes = self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) or []
for mode in modes:
@@ -1631,7 +1640,7 @@ class AlexaModeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Water heater operation modes
if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
if self.instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
operation_modes = (
self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) or []
@@ -1651,7 +1660,7 @@ class AlexaModeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Remote Resource
if self.instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
if self.instance == f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}":
# Use the mode controller for a remote because the input controller
# only allows a preset of names as an input.
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
@@ -1669,7 +1678,7 @@ class AlexaModeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Cover Position Resources
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
self._resource = AlexaModeResource(
["Position", AlexaGlobalCatalog.SETTING_OPENING], False
)
@@ -1688,7 +1697,7 @@ class AlexaModeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Valve position resources
if self.instance == f"{valve.DOMAIN}.state":
if self.instance == f"{VALVE_DOMAIN}.state":
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
self._resource = AlexaModeResource(
["Preset", AlexaGlobalCatalog.SETTING_PRESET], False
@@ -1721,7 +1730,7 @@ class AlexaModeController(AlexaCapability):
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
# Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
lower_labels = [AlexaSemantics.ACTION_LOWER]
raise_labels = [AlexaSemantics.ACTION_RAISE]
self._semantics = AlexaSemantics()
@@ -1753,7 +1762,7 @@ class AlexaModeController(AlexaCapability):
return self._semantics.serialize_semantics()
# Valve Position
if self.instance == f"{valve.DOMAIN}.state":
if self.instance == f"{VALVE_DOMAIN}.state":
close_labels = [AlexaSemantics.ACTION_CLOSE]
open_labels = [AlexaSemantics.ACTION_OPEN]
self._semantics = AlexaSemantics()
@@ -1861,43 +1870,43 @@ class AlexaRangeController(AlexaCapability):
return None
# Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION)
# Cover Tilt
if self.instance == f"{cover.DOMAIN}.tilt":
if self.instance == f"{COVER_DOMAIN}.tilt":
return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION)
# Fan speed percentage
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}":
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported and fan.FanEntityFeature.SET_SPEED:
return self.entity.attributes.get(fan.ATTR_PERCENTAGE)
return 100 if self.entity.state == fan.STATE_ON else 0
# Humidifier target humidity
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}":
# If the humidifier is turned off the target humidity attribute is not set.
# We return 0 to make clear we do not know the current value.
return self.entity.attributes.get(humidifier.ATTR_HUMIDITY, 0)
# Input Number Value
if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}":
if self.instance == f"{INPUT_NUMBER_DOMAIN}.{input_number.ATTR_VALUE}":
return float(self.entity.state)
# Number Value
if self.instance == f"{number.DOMAIN}.{number.ATTR_VALUE}":
if self.instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}":
return float(self.entity.state)
# Vacuum Fan Speed
if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
if self.instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
speed_list = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED_LIST)
speed = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED)
if speed_list is not None and speed is not None:
return next((i for i, v in enumerate(speed_list) if v == speed), None)
# Valve Position
if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}":
if self.instance == f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}":
return self.entity.attributes.get(valve.ATTR_CURRENT_POSITION)
return None
@@ -1915,7 +1924,7 @@ class AlexaRangeController(AlexaCapability):
"""Return capabilityResources object."""
# Fan Speed Percentage Resources
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}":
percentage_step = self.entity.attributes.get(fan.ATTR_PERCENTAGE_STEP)
self._resource = AlexaPresetResource(
labels=["Percentage", AlexaGlobalCatalog.SETTING_FAN_SPEED],
@@ -1929,7 +1938,7 @@ class AlexaRangeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Humidifier Target Humidity Resources
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}":
self._resource = AlexaPresetResource(
labels=["Humidity", "Percentage", "Target humidity"],
min_value=self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10),
@@ -1940,7 +1949,7 @@ class AlexaRangeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Cover Position Resources
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
self._resource = AlexaPresetResource(
["Position", AlexaGlobalCatalog.SETTING_OPENING],
min_value=0,
@@ -1951,7 +1960,7 @@ class AlexaRangeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Cover Tilt Resources
if self.instance == f"{cover.DOMAIN}.tilt":
if self.instance == f"{COVER_DOMAIN}.tilt":
self._resource = AlexaPresetResource(
["Tilt", "Angle", AlexaGlobalCatalog.SETTING_DIRECTION],
min_value=0,
@@ -1962,7 +1971,7 @@ class AlexaRangeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Input Number Value
if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}":
if self.instance == f"{INPUT_NUMBER_DOMAIN}.{input_number.ATTR_VALUE}":
min_value = float(self.entity.attributes[input_number.ATTR_MIN])
max_value = float(self.entity.attributes[input_number.ATTR_MAX])
precision = float(self.entity.attributes.get(input_number.ATTR_STEP, 1))
@@ -1984,7 +1993,7 @@ class AlexaRangeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Number Value
if self.instance == f"{number.DOMAIN}.{number.ATTR_VALUE}":
if self.instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}":
min_value = float(self.entity.attributes[number.ATTR_MIN])
max_value = float(self.entity.attributes[number.ATTR_MAX])
precision = float(self.entity.attributes.get(number.ATTR_STEP, 1))
@@ -2006,7 +2015,7 @@ class AlexaRangeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Vacuum Fan Speed Resources
if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
if self.instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
speed_list = self.entity.attributes[vacuum.ATTR_FAN_SPEED_LIST]
max_value = len(speed_list) - 1
self._resource = AlexaPresetResource(
@@ -2026,7 +2035,7 @@ class AlexaRangeController(AlexaCapability):
return self._resource.serialize_capability_resources()
# Valve Position Resources
if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}":
if self.instance == f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}":
self._resource = AlexaPresetResource(
["Opening", AlexaGlobalCatalog.SETTING_OPENING],
min_value=0,
@@ -2044,7 +2053,7 @@ class AlexaRangeController(AlexaCapability):
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
# Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
lower_labels = [AlexaSemantics.ACTION_LOWER]
raise_labels = [AlexaSemantics.ACTION_RAISE]
self._semantics = AlexaSemantics()
@@ -2069,7 +2078,7 @@ class AlexaRangeController(AlexaCapability):
return self._semantics.serialize_semantics()
# Cover Tilt
if self.instance == f"{cover.DOMAIN}.tilt":
if self.instance == f"{COVER_DOMAIN}.tilt":
self._semantics = AlexaSemantics()
self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_CLOSE], "SetRangeValue", {"rangeValue": 0}
@@ -2084,7 +2093,7 @@ class AlexaRangeController(AlexaCapability):
return self._semantics.serialize_semantics()
# Fan Speed Percentage
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}":
lower_labels = [AlexaSemantics.ACTION_LOWER]
raise_labels = [AlexaSemantics.ACTION_RAISE]
self._semantics = AlexaSemantics()
@@ -2098,7 +2107,7 @@ class AlexaRangeController(AlexaCapability):
return self._semantics.serialize_semantics()
# Target Humidity Percentage
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}":
lower_labels = [AlexaSemantics.ACTION_LOWER]
raise_labels = [AlexaSemantics.ACTION_RAISE]
self._semantics = AlexaSemantics()
@@ -2114,7 +2123,7 @@ class AlexaRangeController(AlexaCapability):
return self._semantics.serialize_semantics()
# Valve Position
if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}":
if self.instance == f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}":
close_labels = [AlexaSemantics.ACTION_CLOSE]
open_labels = [AlexaSemantics.ACTION_OPEN]
self._semantics = AlexaSemantics()
@@ -2207,12 +2216,12 @@ class AlexaToggleController(AlexaCapability):
raise UnsupportedProperty(name)
# Fan Oscillating
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}":
is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING))
return "ON" if is_on else "OFF"
# Stop Valve
if self.instance == f"{valve.DOMAIN}.stop":
if self.instance == f"{VALVE_DOMAIN}.stop":
return "OFF"
return None
@@ -2222,13 +2231,13 @@ class AlexaToggleController(AlexaCapability):
"""Return capabilityResources object."""
# Fan Oscillating Resource
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}":
self._resource = AlexaCapabilityResource(
[AlexaGlobalCatalog.SETTING_OSCILLATE, "Rotate", "Rotation"]
)
return self._resource.serialize_capability_resources()
if self.instance == f"{valve.DOMAIN}.stop":
if self.instance == f"{VALVE_DOMAIN}.stop":
self._resource = AlexaCapabilityResource(["Stop"])
return self._resource.serialize_capability_resources()
@@ -2438,12 +2447,12 @@ class AlexaEventDetectionSensor(AlexaCapability):
if state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
return None
if self.entity.domain == image_processing.DOMAIN:
if self.entity.domain == IMAGE_PROCESSING_DOMAIN:
if int(state):
human_presence = "DETECTED"
elif state == STATE_ON or self.entity.domain in [
input_button.DOMAIN,
button.DOMAIN,
INPUT_BUTTON_DOMAIN,
BUTTON_DOMAIN,
]:
human_presence = "DETECTED"
@@ -2458,7 +2467,7 @@ class AlexaEventDetectionSensor(AlexaCapability):
"humanPresence": {
"featureAvailability": "ENABLED",
"supportsNotDetected": self.entity.domain
not in [input_button.DOMAIN, button.DOMAIN],
not in [INPUT_BUTTON_DOMAIN, BUTTON_DOMAIN],
}
},
}
+94 -74
View File
@@ -5,36 +5,52 @@ import logging
from typing import TYPE_CHECKING, Any, override
from homeassistant.components import (
alarm_control_panel,
alert,
automation,
binary_sensor,
button,
camera,
climate,
cover,
event,
fan,
group,
humidifier,
image_processing,
input_boolean,
input_button,
input_number,
light,
lock,
media_player,
number,
remote,
scene,
script,
sensor,
switch,
timer,
vacuum,
valve,
water_heater,
)
from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
)
from homeassistant.components.alert import DOMAIN as ALERT_DOMAIN
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.group import DOMAIN as GROUP_DOMAIN
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN
from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN
from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_SUPPORTED_FEATURES,
@@ -45,7 +61,7 @@ from homeassistant.const import (
__version__,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import network
from homeassistant.helpers import entity_registry as er, intent, network
from homeassistant.helpers.entity import entity_sources
from homeassistant.util.decorator import Registry
@@ -283,10 +299,14 @@ class AlexaEntity:
def friendly_name(self) -> str:
"""Return the Alexa API friendly name."""
friendly_name: str = self.entity_conf.get(
CONF_NAME, self.entity.name
).translate(TRANSLATION_TABLE)
return friendly_name
name: str | None = self.entity_conf.get(CONF_NAME)
if name is None:
entity_entry = er.async_get(self.hass).async_get(self.entity_id)
aliases = intent.async_get_entity_aliases(
self.hass, entity_entry, state=self.entity, allow_empty=False
)
name = aliases[0]
return name.translate(TRANSLATION_TABLE)
def description(self) -> str:
"""Return the Alexa API description."""
@@ -388,9 +408,9 @@ def async_get_entities(
return entities
@ENTITY_ADAPTERS.register(alert.DOMAIN)
@ENTITY_ADAPTERS.register(automation.DOMAIN)
@ENTITY_ADAPTERS.register(group.DOMAIN)
@ENTITY_ADAPTERS.register(ALERT_DOMAIN)
@ENTITY_ADAPTERS.register(AUTOMATION_DOMAIN)
@ENTITY_ADAPTERS.register(GROUP_DOMAIN)
class GenericCapabilities(AlexaEntity):
"""A generic, on/off device.
@@ -400,7 +420,7 @@ class GenericCapabilities(AlexaEntity):
@override
def default_display_categories(self) -> list[str]:
"""Return the display categories for this entity."""
if self.entity.domain == automation.DOMAIN:
if self.entity.domain == AUTOMATION_DOMAIN:
return [DisplayCategory.ACTIVITY_TRIGGER]
return [DisplayCategory.OTHER]
@@ -413,15 +433,15 @@ class GenericCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(input_boolean.DOMAIN)
@ENTITY_ADAPTERS.register(switch.DOMAIN)
@ENTITY_ADAPTERS.register(INPUT_BOOLEAN_DOMAIN)
@ENTITY_ADAPTERS.register(SWITCH_DOMAIN)
class SwitchCapabilities(AlexaEntity):
"""Class to represent Switch capabilities."""
@override
def default_display_categories(self) -> list[str]:
"""Return the display categories for this entity."""
if self.entity.domain == input_boolean.DOMAIN:
if self.entity.domain == INPUT_BOOLEAN_DOMAIN:
return [DisplayCategory.OTHER]
device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS)
@@ -439,8 +459,8 @@ class SwitchCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(button.DOMAIN)
@ENTITY_ADAPTERS.register(input_button.DOMAIN)
@ENTITY_ADAPTERS.register(BUTTON_DOMAIN)
@ENTITY_ADAPTERS.register(INPUT_BUTTON_DOMAIN)
class ButtonCapabilities(AlexaEntity):
"""Class to represent Button capabilities."""
@@ -458,15 +478,15 @@ class ButtonCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(climate.DOMAIN)
@ENTITY_ADAPTERS.register(water_heater.DOMAIN)
@ENTITY_ADAPTERS.register(CLIMATE_DOMAIN)
@ENTITY_ADAPTERS.register(WATER_HEATER_DOMAIN)
class ClimateCapabilities(AlexaEntity):
"""Class to represent Climate capabilities."""
@override
def default_display_categories(self) -> list[str]:
"""Return the display categories for this entity."""
if self.entity.domain == water_heater.DOMAIN:
if self.entity.domain == WATER_HEATER_DOMAIN:
return [DisplayCategory.WATER_HEATER]
return [DisplayCategory.THERMOSTAT]
@@ -477,12 +497,12 @@ class ClimateCapabilities(AlexaEntity):
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (
(
self.entity.domain == climate.DOMAIN
self.entity.domain == CLIMATE_DOMAIN
and climate.HVACMode.OFF
in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [])
)
or (
self.entity.domain == climate.DOMAIN
self.entity.domain == CLIMATE_DOMAIN
and (
supported_features
& (
@@ -492,14 +512,14 @@ class ClimateCapabilities(AlexaEntity):
)
)
or (
self.entity.domain == water_heater.DOMAIN
self.entity.domain == WATER_HEATER_DOMAIN
and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF)
)
):
yield AlexaPowerController(self.entity)
if self.entity.domain == climate.DOMAIN or (
self.entity.domain == water_heater.DOMAIN
if self.entity.domain == CLIMATE_DOMAIN or (
self.entity.domain == WATER_HEATER_DOMAIN
and (
supported_features
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
@@ -508,7 +528,7 @@ class ClimateCapabilities(AlexaEntity):
yield AlexaThermostatController(self.hass, self.entity)
yield AlexaTemperatureSensor(self.hass, self.entity)
if (
self.entity.domain == water_heater.DOMAIN
self.entity.domain == WATER_HEATER_DOMAIN
and (
supported_features
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
@@ -517,13 +537,13 @@ class ClimateCapabilities(AlexaEntity):
):
yield AlexaModeController(
self.entity,
instance=f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}",
instance=f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}",
)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(cover.DOMAIN)
@ENTITY_ADAPTERS.register(COVER_DOMAIN)
class CoverCapabilities(AlexaEntity):
"""Class to represent Cover capabilities."""
@@ -563,25 +583,25 @@ class CoverCapabilities(AlexaEntity):
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & cover.CoverEntityFeature.SET_POSITION:
yield AlexaRangeController(
self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}"
self.entity, instance=f"{COVER_DOMAIN}.{cover.ATTR_POSITION}"
)
elif supported & (
cover.CoverEntityFeature.CLOSE | cover.CoverEntityFeature.OPEN
):
yield AlexaModeController(
self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}"
self.entity, instance=f"{COVER_DOMAIN}.{cover.ATTR_POSITION}"
)
if supported & cover.CoverEntityFeature.SET_TILT_POSITION:
yield AlexaRangeController(self.entity, instance=f"{cover.DOMAIN}.tilt")
yield AlexaRangeController(self.entity, instance=f"{COVER_DOMAIN}.tilt")
if supported & (
cover.CoverEntityFeature.STOP | cover.CoverEntityFeature.STOP_TILT
):
yield AlexaPlaybackController(self.entity, instance=f"{cover.DOMAIN}.stop")
yield AlexaPlaybackController(self.entity, instance=f"{COVER_DOMAIN}.stop")
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(event.DOMAIN)
@ENTITY_ADAPTERS.register(EVENT_DOMAIN)
class EventCapabilities(AlexaEntity):
"""Class to represent doorbel event capabilities."""
@@ -603,7 +623,7 @@ class EventCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(light.DOMAIN)
@ENTITY_ADAPTERS.register(LIGHT_DOMAIN)
class LightCapabilities(AlexaEntity):
"""Class to represent Light capabilities."""
@@ -629,7 +649,7 @@ class LightCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(fan.DOMAIN)
@ENTITY_ADAPTERS.register(FAN_DOMAIN)
class FanCapabilities(AlexaEntity):
"""Class to represent Fan capabilities."""
@@ -646,19 +666,19 @@ class FanCapabilities(AlexaEntity):
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & fan.FanEntityFeature.OSCILLATE:
yield AlexaToggleController(
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
self.entity, instance=f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}"
)
force_range_controller = False
if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get(
fan.ATTR_PRESET_MODES
):
yield AlexaModeController(
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
self.entity, instance=f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}"
)
force_range_controller = False
if supported & fan.FanEntityFeature.DIRECTION:
yield AlexaModeController(
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}"
self.entity, instance=f"{FAN_DOMAIN}.{fan.ATTR_DIRECTION}"
)
force_range_controller = False
@@ -669,14 +689,14 @@ class FanCapabilities(AlexaEntity):
# can only be set to 0% or 100%.
if force_range_controller or supported & fan.FanEntityFeature.SET_SPEED:
yield AlexaRangeController(
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}"
self.entity, instance=f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}"
)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(remote.DOMAIN)
@ENTITY_ADAPTERS.register(REMOTE_DOMAIN)
class RemoteCapabilities(AlexaEntity):
"""Class to represent Remote capabilities."""
@@ -697,13 +717,13 @@ class RemoteCapabilities(AlexaEntity):
and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
):
yield AlexaModeController(
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
self.entity, instance=f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}"
)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(humidifier.DOMAIN)
@ENTITY_ADAPTERS.register(HUMIDIFIER_DOMAIN)
class HumidifierCapabilities(AlexaEntity):
"""Class to represent Humidifier capabilities."""
@@ -721,17 +741,17 @@ class HumidifierCapabilities(AlexaEntity):
supported & humidifier.HumidifierEntityFeature.MODES
) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES):
yield AlexaModeController(
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
self.entity, instance=f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}"
)
yield AlexaRangeController(
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}"
self.entity, instance=f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}"
)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(lock.DOMAIN)
@ENTITY_ADAPTERS.register(LOCK_DOMAIN)
class LockCapabilities(AlexaEntity):
"""Class to represent Lock capabilities."""
@@ -748,7 +768,7 @@ class LockCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(media_player.DOMAIN)
@ENTITY_ADAPTERS.register(MEDIA_PLAYER_DOMAIN)
class MediaPlayerCapabilities(AlexaEntity):
"""Class to represent MediaPlayer capabilities."""
@@ -814,7 +834,7 @@ class MediaPlayerCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(scene.DOMAIN)
@ENTITY_ADAPTERS.register(SCENE_DOMAIN)
class SceneCapabilities(AlexaEntity):
"""Class to represent Scene capabilities."""
@@ -838,7 +858,7 @@ class SceneCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(script.DOMAIN)
@ENTITY_ADAPTERS.register(SCRIPT_DOMAIN)
class ScriptCapabilities(AlexaEntity):
"""Class to represent Script capabilities."""
@@ -854,7 +874,7 @@ class ScriptCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(sensor.DOMAIN)
@ENTITY_ADAPTERS.register(SENSOR_DOMAIN)
class SensorCapabilities(AlexaEntity):
"""Class to represent Sensor capabilities."""
@@ -878,7 +898,7 @@ class SensorCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN)
@ENTITY_ADAPTERS.register(BINARY_SENSOR_DOMAIN)
class BinarySensorCapabilities(AlexaEntity):
"""Class to represent BinarySensor capabilities."""
@@ -947,7 +967,7 @@ class BinarySensorCapabilities(AlexaEntity):
return None
@ENTITY_ADAPTERS.register(alarm_control_panel.DOMAIN)
@ENTITY_ADAPTERS.register(ALARM_CONTROL_PANEL_DOMAIN)
class AlarmControlPanelCapabilities(AlexaEntity):
"""Class to represent Alarm capabilities."""
@@ -965,7 +985,7 @@ class AlarmControlPanelCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(image_processing.DOMAIN)
@ENTITY_ADAPTERS.register(IMAGE_PROCESSING_DOMAIN)
class ImageProcessingCapabilities(AlexaEntity):
"""Class to represent image_processing capabilities."""
@@ -982,8 +1002,8 @@ class ImageProcessingCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(input_number.DOMAIN)
@ENTITY_ADAPTERS.register(number.DOMAIN)
@ENTITY_ADAPTERS.register(INPUT_NUMBER_DOMAIN)
@ENTITY_ADAPTERS.register(NUMBER_DOMAIN)
class InputNumberCapabilities(AlexaEntity):
"""Class to represent number and input_number capabilities."""
@@ -1001,7 +1021,7 @@ class InputNumberCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(timer.DOMAIN)
@ENTITY_ADAPTERS.register(TIMER_DOMAIN)
class TimerCapabilities(AlexaEntity):
"""Class to represent Timer capabilities."""
@@ -1018,7 +1038,7 @@ class TimerCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(vacuum.DOMAIN)
@ENTITY_ADAPTERS.register(VACUUM_DOMAIN)
class VacuumCapabilities(AlexaEntity):
"""Class to represent vacuum capabilities."""
@@ -1042,7 +1062,7 @@ class VacuumCapabilities(AlexaEntity):
if supported & vacuum.VacuumEntityFeature.FAN_SPEED:
yield AlexaRangeController(
self.entity, instance=f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}"
self.entity, instance=f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}"
)
if supported & vacuum.VacuumEntityFeature.PAUSE:
@@ -1055,7 +1075,7 @@ class VacuumCapabilities(AlexaEntity):
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(valve.DOMAIN)
@ENTITY_ADAPTERS.register(VALVE_DOMAIN)
class ValveCapabilities(AlexaEntity):
"""Class to represent Valve capabilities."""
@@ -1070,19 +1090,19 @@ class ValveCapabilities(AlexaEntity):
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & valve.ValveEntityFeature.SET_POSITION:
yield AlexaRangeController(
self.entity, instance=f"{valve.DOMAIN}.{valve.ATTR_POSITION}"
self.entity, instance=f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}"
)
elif supported & (
valve.ValveEntityFeature.CLOSE | valve.ValveEntityFeature.OPEN
):
yield AlexaModeController(self.entity, instance=f"{valve.DOMAIN}.state")
yield AlexaModeController(self.entity, instance=f"{VALVE_DOMAIN}.state")
if supported & valve.ValveEntityFeature.STOP:
yield AlexaToggleController(self.entity, instance=f"{valve.DOMAIN}.stop")
yield AlexaToggleController(self.entity, instance=f"{VALVE_DOMAIN}.stop")
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(camera.DOMAIN)
@ENTITY_ADAPTERS.register(CAMERA_DOMAIN)
class CameraCapabilities(AlexaEntity):
"""Class to represent Camera capabilities."""
+72 -58
View File
@@ -14,7 +14,6 @@ from homeassistant.components import (
climate,
cover,
fan,
group,
humidifier,
input_button,
input_number,
@@ -27,6 +26,21 @@ from homeassistant.components import (
valve,
water_heater,
)
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.group import DOMAIN as GROUP_DOMAIN
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
@@ -88,19 +102,19 @@ _LOGGER = logging.getLogger(__name__)
DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive"
MIN_MAX_TEMP = {
climate.DOMAIN: {
CLIMATE_DOMAIN: {
"min_temp": climate.ATTR_MIN_TEMP,
"max_temp": climate.ATTR_MAX_TEMP,
},
water_heater.DOMAIN: {
WATER_HEATER_DOMAIN: {
"min_temp": water_heater.ATTR_MIN_TEMP,
"max_temp": water_heater.ATTR_MAX_TEMP,
},
}
SERVICE_SET_TEMPERATURE = {
climate.DOMAIN: climate.SERVICE_SET_TEMPERATURE,
water_heater.DOMAIN: water_heater.SERVICE_SET_TEMPERATURE,
CLIMATE_DOMAIN: climate.SERVICE_SET_TEMPERATURE,
WATER_HEATER_DOMAIN: water_heater.SERVICE_SET_TEMPERATURE,
}
HANDLERS: Registry[
@@ -176,30 +190,30 @@ async def async_api_turn_on(
) -> AlexaResponse:
"""Process a turn on request."""
entity = directive.entity
if (domain := entity.domain) == group.DOMAIN:
if (domain := entity.domain) == GROUP_DOMAIN:
domain = ha.DOMAIN
service = SERVICE_TURN_ON
if domain == cover.DOMAIN:
if domain == COVER_DOMAIN:
service = cover.SERVICE_OPEN_COVER
elif domain == climate.DOMAIN:
elif domain == CLIMATE_DOMAIN:
service = climate.SERVICE_TURN_ON
elif domain == fan.DOMAIN:
elif domain == FAN_DOMAIN:
service = fan.SERVICE_TURN_ON
elif domain == humidifier.DOMAIN:
elif domain == HUMIDIFIER_DOMAIN:
service = humidifier.SERVICE_TURN_ON
elif domain == remote.DOMAIN:
elif domain == REMOTE_DOMAIN:
service = remote.SERVICE_TURN_ON
elif domain == vacuum.DOMAIN:
elif domain == VACUUM_DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (
not supported & vacuum.VacuumEntityFeature.TURN_ON
and supported & vacuum.VacuumEntityFeature.START
):
service = vacuum.SERVICE_START
elif domain == timer.DOMAIN:
elif domain == TIMER_DOMAIN:
service = timer.SERVICE_START
elif domain == media_player.DOMAIN:
elif domain == MEDIA_PLAYER_DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
power_features = (
media_player.MediaPlayerEntityFeature.TURN_ON
@@ -229,30 +243,30 @@ async def async_api_turn_off(
"""Process a turn off request."""
entity = directive.entity
domain = entity.domain
if entity.domain == group.DOMAIN:
if entity.domain == GROUP_DOMAIN:
domain = ha.DOMAIN
service = SERVICE_TURN_OFF
if entity.domain == cover.DOMAIN:
if entity.domain == COVER_DOMAIN:
service = cover.SERVICE_CLOSE_COVER
elif domain == climate.DOMAIN:
elif domain == CLIMATE_DOMAIN:
service = climate.SERVICE_TURN_OFF
elif domain == fan.DOMAIN:
elif domain == FAN_DOMAIN:
service = fan.SERVICE_TURN_OFF
elif domain == remote.DOMAIN:
elif domain == REMOTE_DOMAIN:
service = remote.SERVICE_TURN_OFF
elif domain == humidifier.DOMAIN:
elif domain == HUMIDIFIER_DOMAIN:
service = humidifier.SERVICE_TURN_OFF
elif domain == vacuum.DOMAIN:
elif domain == VACUUM_DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (
not supported & vacuum.VacuumEntityFeature.TURN_OFF
and supported & vacuum.VacuumEntityFeature.RETURN_HOME
):
service = vacuum.SERVICE_RETURN_TO_BASE
elif domain == timer.DOMAIN:
elif domain == TIMER_DOMAIN:
service = timer.SERVICE_CANCEL
elif domain == media_player.DOMAIN:
elif domain == MEDIA_PLAYER_DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
power_features = (
media_player.MediaPlayerEntityFeature.TURN_ON
@@ -428,9 +442,9 @@ async def async_api_activate(
domain = entity.domain
service = SERVICE_TURN_ON
if domain == button.DOMAIN:
if domain == BUTTON_DOMAIN:
service = button.SERVICE_PRESS
elif domain == input_button.DOMAIN:
elif domain == INPUT_BUTTON_DOMAIN:
service = input_button.SERVICE_PRESS
await hass.services.async_call(
@@ -767,7 +781,7 @@ async def async_api_stop(
entity = directive.entity
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if entity.domain == cover.DOMAIN:
if entity.domain == COVER_DOMAIN:
supported: int = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
feature_services: dict[int, str] = {
cover.CoverEntityFeature.STOP.value: cover.SERVICE_STOP_COVER,
@@ -1065,7 +1079,7 @@ async def async_api_set_thermostat_mode(
response = directive.response()
await hass.services.async_call(
climate.DOMAIN, service, data, blocking=False, context=context
CLIMATE_DOMAIN, service, data, blocking=False, context=context
)
response.add_context_property(
{
@@ -1198,14 +1212,14 @@ async def async_api_set_mode(
mode = directive.payload["mode"]
# Fan Direction
if instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}":
if instance == f"{FAN_DOMAIN}.{fan.ATTR_DIRECTION}":
direction = mode.split(".")[1]
if direction in (fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD):
service = fan.SERVICE_SET_DIRECTION
data[fan.ATTR_DIRECTION] = direction
# Fan preset_mode
elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}":
elif instance == f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}":
preset_mode = mode.split(".")[1]
preset_modes: list[str] | None = entity.attributes.get(fan.ATTR_PRESET_MODES)
if (
@@ -1220,7 +1234,7 @@ async def async_api_set_mode(
raise AlexaInvalidValueError(msg)
# Humidifier mode
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}":
elif instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}":
mode = mode.split(".")[1]
modes: list[str] | None = entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES)
if mode != PRESET_MODE_NA and modes and mode in modes:
@@ -1231,7 +1245,7 @@ async def async_api_set_mode(
raise AlexaInvalidValueError(msg)
# Remote Activity
elif instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
elif instance == f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}":
activity = mode.split(".")[1]
activities: list[str] | None = entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
if activity != PRESET_MODE_NA and activities and activity in activities:
@@ -1242,7 +1256,7 @@ async def async_api_set_mode(
raise AlexaInvalidValueError(msg)
# Water heater operation mode
elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
elif instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = mode.split(".")[1]
operation_modes: list[str] | None = entity.attributes.get(
water_heater.ATTR_OPERATION_LIST
@@ -1262,7 +1276,7 @@ async def async_api_set_mode(
raise AlexaInvalidValueError(msg)
# Cover Position
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
elif instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
position = mode.split(".")[1]
if position == cover.CoverState.CLOSED:
@@ -1273,7 +1287,7 @@ async def async_api_set_mode(
service = cover.SERVICE_STOP_COVER
# Valve position state
elif instance == f"{valve.DOMAIN}.state":
elif instance == f"{VALVE_DOMAIN}.state":
position = mode.split(".")[1]
if position == valve.STATE_CLOSED:
@@ -1334,13 +1348,13 @@ async def async_api_toggle_on(
data: dict[str, Any]
# Fan Oscillating
if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
if instance == f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}":
service = fan.SERVICE_OSCILLATE
data = {
ATTR_ENTITY_ID: entity.entity_id,
fan.ATTR_OSCILLATING: True,
}
elif instance == f"{valve.DOMAIN}.stop":
elif instance == f"{VALVE_DOMAIN}.stop":
service = valve.SERVICE_STOP_VALVE
data = {
ATTR_ENTITY_ID: entity.entity_id,
@@ -1378,7 +1392,7 @@ async def async_api_toggle_off(
domain = entity.domain
# Fan Oscillating
if instance != f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
if instance != f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}":
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
service = fan.SERVICE_OSCILLATE
@@ -1421,7 +1435,7 @@ async def async_api_set_range(
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
# Cover Position
if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
range_value = int(range_value)
if supported & cover.CoverEntityFeature.CLOSE and range_value == 0:
service = cover.SERVICE_CLOSE_COVER
@@ -1432,7 +1446,7 @@ async def async_api_set_range(
data[cover.ATTR_POSITION] = range_value
# Cover Tilt
elif instance == f"{cover.DOMAIN}.tilt":
elif instance == f"{COVER_DOMAIN}.tilt":
range_value = int(range_value)
if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0:
service = cover.SERVICE_CLOSE_COVER_TILT
@@ -1443,7 +1457,7 @@ async def async_api_set_range(
data[cover.ATTR_TILT_POSITION] = range_value
# Fan Speed
elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}":
elif instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}":
range_value = int(range_value)
if range_value == 0:
service = fan.SERVICE_TURN_OFF
@@ -1454,13 +1468,13 @@ async def async_api_set_range(
service = fan.SERVICE_TURN_ON
# Humidifier target humidity
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
elif instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}":
range_value = int(range_value)
service = humidifier.SERVICE_SET_HUMIDITY
data[humidifier.ATTR_HUMIDITY] = range_value
# Input Number Value
elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}":
elif instance == f"{INPUT_NUMBER_DOMAIN}.{input_number.ATTR_VALUE}":
range_value = float(range_value)
service = input_number.SERVICE_SET_VALUE
min_value = float(entity.attributes[input_number.ATTR_MIN])
@@ -1468,7 +1482,7 @@ async def async_api_set_range(
data[input_number.ATTR_VALUE] = min(max_value, max(min_value, range_value))
# Input Number Value
elif instance == f"{number.DOMAIN}.{number.ATTR_VALUE}":
elif instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}":
range_value = float(range_value)
service = number.SERVICE_SET_VALUE
min_value = float(entity.attributes[number.ATTR_MIN])
@@ -1476,7 +1490,7 @@ async def async_api_set_range(
data[number.ATTR_VALUE] = min(max_value, max(min_value, range_value))
# Vacuum Fan Speed
elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
elif instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
service = vacuum.SERVICE_SET_FAN_SPEED
speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST]
speed = next(
@@ -1490,7 +1504,7 @@ async def async_api_set_range(
data[vacuum.ATTR_FAN_SPEED] = speed
# Valve Position
elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}":
elif instance == f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}":
range_value = int(range_value)
if supported & valve.ValveEntityFeature.CLOSE and range_value == 0:
service = valve.SERVICE_CLOSE_VALVE
@@ -1538,7 +1552,7 @@ async def async_api_adjust_range(
response_value: float | None = 0
# Cover Position
if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
range_delta = int(range_delta * 20) if range_delta_default else int(range_delta)
service = SERVICE_SET_COVER_POSITION
if not (current := entity.attributes.get(cover.ATTR_CURRENT_POSITION)):
@@ -1553,7 +1567,7 @@ async def async_api_adjust_range(
data[cover.ATTR_POSITION] = position
# Cover Tilt
elif instance == f"{cover.DOMAIN}.tilt":
elif instance == f"{COVER_DOMAIN}.tilt":
range_delta = int(range_delta * 20) if range_delta_default else int(range_delta)
service = SERVICE_SET_COVER_TILT_POSITION
current = entity.attributes.get(cover.ATTR_TILT_POSITION)
@@ -1569,7 +1583,7 @@ async def async_api_adjust_range(
data[cover.ATTR_TILT_POSITION] = tilt_position
# Fan speed percentage
elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}":
elif instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}":
percentage_step = entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 20
range_delta = (
int(range_delta * percentage_step)
@@ -1587,7 +1601,7 @@ async def async_api_adjust_range(
service = fan.SERVICE_TURN_OFF
# Humidifier target humidity
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
elif instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}":
percentage_step = 5
range_delta = (
int(range_delta * percentage_step)
@@ -1607,7 +1621,7 @@ async def async_api_adjust_range(
data[humidifier.ATTR_HUMIDITY] = percentage
# Input Number Value
elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}":
elif instance == f"{INPUT_NUMBER_DOMAIN}.{input_number.ATTR_VALUE}":
range_delta = float(range_delta)
service = input_number.SERVICE_SET_VALUE
min_value = float(entity.attributes[input_number.ATTR_MIN])
@@ -1618,7 +1632,7 @@ async def async_api_adjust_range(
)
# Number Value
elif instance == f"{number.DOMAIN}.{number.ATTR_VALUE}":
elif instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}":
range_delta = float(range_delta)
service = number.SERVICE_SET_VALUE
min_value = float(entity.attributes[number.ATTR_MIN])
@@ -1629,7 +1643,7 @@ async def async_api_adjust_range(
)
# Vacuum Fan Speed
elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
elif instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
range_delta = int(range_delta)
service = vacuum.SERVICE_SET_FAN_SPEED
speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST]
@@ -1646,7 +1660,7 @@ async def async_api_adjust_range(
data[vacuum.ATTR_FAN_SPEED] = response_value = speed
# Valve Position
elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}":
elif instance == f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}":
range_delta = int(range_delta * 20) if range_delta_default else int(range_delta)
service = valve.SERVICE_SET_VALVE_POSITION
if not (current := entity.attributes.get(valve.ATTR_POSITION)):
@@ -1801,7 +1815,7 @@ async def async_api_seek(
}
await hass.services.async_call(
media_player.DOMAIN,
MEDIA_PLAYER_DOMAIN,
media_player.SERVICE_MEDIA_SEEK,
data,
blocking=False,
@@ -1877,10 +1891,10 @@ async def async_api_hold(
entity = directive.entity
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if entity.domain == timer.DOMAIN:
if entity.domain == TIMER_DOMAIN:
service = timer.SERVICE_PAUSE
elif entity.domain == vacuum.DOMAIN:
elif entity.domain == VACUUM_DOMAIN:
service = vacuum.SERVICE_START_PAUSE
else:
@@ -1904,10 +1918,10 @@ async def async_api_resume(
entity = directive.entity
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if entity.domain == timer.DOMAIN:
if entity.domain == TIMER_DOMAIN:
service = timer.SERVICE_START
elif entity.domain == vacuum.DOMAIN:
elif entity.domain == VACUUM_DOMAIN:
service = vacuum.SERVICE_START_PAUSE
else:
@@ -11,7 +11,7 @@ from uuid import uuid4
import aiohttp
from homeassistant.components import event
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_ON,
@@ -341,7 +341,7 @@ async def async_enable_proactive_mode(
if should_doorbell:
old_state = data["old_state"]
if (
new_state.domain == event.DOMAIN
new_state.domain == EVENT_DOMAIN
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
and (old_state is None or old_state.state != new_state.state)
@@ -1,12 +1,15 @@
"""Alexa Devices integration."""
from collections.abc import Awaitable, Callable
from homeassistant.const import CONF_COUNTRY, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
from .const import CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN, LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .services import async_setup_services
@@ -31,6 +34,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def _async_initial_sync(sync_call: Callable[[], Awaitable[None]]) -> None:
"""Run an initial best-effort sync call.
These syncs are not required for setup to succeed: a failing Amazon API
call must not prevent the other syncs from running or abort setup.
"""
try:
await sync_call()
except ConfigEntryNotReady as err:
LOGGER.warning(
"Initial sync failed for %s: %s. Data may be missing or incomplete until updates are pushed by Amazon",
sync_call.__name__,
err,
)
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Set up Alexa Devices platform."""
@@ -39,9 +58,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
await coordinator.async_config_entry_first_refresh()
await coordinator.sync_todo_list_items()
await coordinator.sync_history_state()
await coordinator.sync_media_state()
for sync_call in (
coordinator.sync_todo_list_items,
coordinator.sync_history_state,
coordinator.sync_media_state,
):
await _async_initial_sync(sync_call)
async def _on_http2_reauth_required() -> None:
entry.async_start_reauth(hass)
@@ -92,9 +114,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) ->
hass.config_entries.async_update_entry(entry, version=1, minor_version=3)
return True
_LOGGER.debug(
"Migrating from version %s.%s", entry.version, entry.minor_version
)
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
# Convert country in domain
country = entry.data[CONF_COUNTRY].lower()
@@ -108,7 +128,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) ->
entry, data=new_data, version=1, minor_version=3
)
_LOGGER.info(
LOGGER.info(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_update_unique_id
@@ -120,7 +120,7 @@ async def async_setup_entry(
if entity_id := entity_registry.async_get_entity_id(
Platform.BINARY_SENSOR, DOMAIN, unique_id
):
_LOGGER.debug("Removing deprecated entity %s", entity_id)
LOGGER.debug("Removing deprecated entity %s", entity_id)
entity_registry.async_remove(entity_id)
known_devices: set[str] = set()
@@ -1,5 +1,6 @@
"""Config flow for Alexa Devices integration."""
import asyncio
from collections.abc import Mapping
from typing import Any, override
@@ -19,6 +20,13 @@ import homeassistant.helpers.config_validation as cv
from .const import CONF_LOGIN_DATA, DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_CODE): cv.string,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): cv.string,
@@ -52,40 +60,71 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 3
_login_data: dict[str, Any]
def __init__(self) -> None:
"""Initialize a new AmazonDevicesConfigFlow."""
self._login_task: asyncio.Task[dict[str, Any]] | None = None
self._login_errors: dict[str, str] = {}
self._login_result: dict[str, Any] = {}
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
if user_input:
try:
data = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except CannotAuthenticate:
errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"
else:
await self.async_set_unique_id(data["customer_info"]["user_id"])
self._abort_if_unique_id_configured()
user_input.pop(CONF_CODE)
return self.async_create_entry(
title=user_input[CONF_USERNAME],
data=user_input | {CONF_LOGIN_DATA: data},
)
if user_input is not None:
self._login_data = user_input
return await self.async_step_login()
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_CODE): cv.string,
}
),
data_schema=STEP_USER_DATA_SCHEMA,
errors=self._login_errors,
)
async def async_step_login(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Log in and scan for devices, showing progress to the user."""
if (login_task := self._login_task) and login_task.done():
self._login_errors = {}
try:
self._login_result = login_task.result()
except CannotConnect:
self._login_errors = {"base": "cannot_connect"}
except CannotAuthenticate:
self._login_errors = {"base": "invalid_auth"}
except CannotRetrieveData:
self._login_errors = {"base": "cannot_retrieve_data"}
finally:
self._login_task = None
return self.async_show_progress_done(
next_step_id="user" if self._login_errors else "login_done"
)
if self._login_task is None:
self._login_task = self.hass.async_create_task(
validate_input(self.hass, self._login_data)
)
return self.async_show_progress(
step_id="login",
progress_action="login",
progress_task=self._login_task,
)
async def async_step_login_done(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Create the config entry after a successful login."""
await self.async_set_unique_id(self._login_result["customer_info"]["user_id"])
self._abort_if_unique_id_configured()
self._login_data.pop(CONF_CODE)
return self.async_create_entry(
title=self._login_data[CONF_USERNAME],
data=self._login_data | {CONF_LOGIN_DATA: self._login_result},
)
async def async_step_reauth(
@@ -2,7 +2,7 @@
import logging
_LOGGER = logging.getLogger(__package__)
LOGGER = logging.getLogger(__package__)
DOMAIN = "alexa_devices"
CONF_LOGIN_DATA = "login_data"
@@ -35,7 +35,7 @@ from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
from .const import CONF_LOGIN_DATA, DOMAIN, LOGGER
SCAN_INTERVAL = 300
@@ -115,12 +115,12 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
"""Initialize the scanner."""
super().__init__(
hass,
_LOGGER,
LOGGER,
name=entry.title,
config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=SCAN_INTERVAL, immediate=False
hass, LOGGER, cooldown=SCAN_INTERVAL, immediate=False
),
)
self.api = AmazonEchoApi(
@@ -232,18 +232,15 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
device_registry = dr.async_get(self.hass)
for serial_num in stale_devices:
_LOGGER.debug(
LOGGER.debug(
"Detected change in devices: serial %s removed",
serial_num,
)
device = device_registry.async_get_device(
identifiers={(DOMAIN, serial_num)}
device = device_registry.async_get_device_by_identifier(
(DOMAIN, serial_num), self.config_entry.entry_id
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
device_registry.async_remove_device(device.id)
async def _async_remove_routine_stale(
self,
@@ -259,7 +256,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
routine_unique_id,
)
if entity_id:
_LOGGER.debug(
LOGGER.debug(
"Detected change in routines: routine %s removed",
routine_unique_id.replace(
f"{slugify(self.config_entry.unique_id)}-", ""
@@ -281,7 +278,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
todo_list_unique_id,
)
if entity_id:
_LOGGER.debug(
LOGGER.debug(
"Detected change in todo lists: todo list entity %s removed",
entity_id,
)
@@ -302,7 +299,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def todo_event_handler(self, list_event: AmazonListEvent) -> None:
"""Handle changes on To-Do lists."""
if list_event.type == AmazonListEventType.DELETED:
self._todo_list_items[list_event.list_id].pop(list_event.item_id, None)
self._todo_list_items.get(list_event.list_id, {}).pop(
list_event.item_id, None
)
elif (
list_event.type
in (AmazonListEventType.UPDATED, AmazonListEventType.CREATED)
@@ -12,7 +12,7 @@ from homeassistant.components.event import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .const import LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import async_remove_entity_from_virtual_group
@@ -78,7 +78,7 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
self.device.serial_number
)
):
_LOGGER.debug(
LOGGER.debug(
"No vocal record found for device %s [%s]",
self.device.account_name,
self.device.serial_number,

Some files were not shown because too many files have changed in this diff Show More