mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Merge branch 'add-librenms-integration' into librenms/add-system-sensors
This commit is contained in:
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -6,7 +6,7 @@ description: Reviews Home Assistant code changes and provides constructive feedb
|
||||
# Review Code Changes
|
||||
|
||||
## Scope:
|
||||
- Unless instructed otherwise, review the full branch changes 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)"..HEAD`; use `dev` as the default base.
|
||||
- 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
|
||||
@@ -16,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. No content from the template should be missing, except for HTML comments. Even unchecked checkboxes or empty sections must be present. This is a 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.
|
||||
|
||||
@@ -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-/"],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"maintenance": false
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+314
-271
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
|
||||
@@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -8,32 +8,48 @@ on:
|
||||
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 }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version }}-${{ inputs.image_prefixes }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
boot_check:
|
||||
name: Boot check ${{ matrix.arch }} core image
|
||||
name: Boot check ${{ matrix.image_prefix }} image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
# 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:
|
||||
include:
|
||||
- arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
image_prefix: ${{ fromJson(inputs.image_prefixes) }}
|
||||
env:
|
||||
BASE_URL: http://localhost:8123
|
||||
services:
|
||||
homeassistant:
|
||||
image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images]
|
||||
# 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)
|
||||
@@ -44,17 +60,17 @@ jobs:
|
||||
--health-retries=60
|
||||
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 pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -76,7 +92,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-${{ matrix.arch }}
|
||||
name: playwright-report-${{ matrix.image_prefix }}
|
||||
path: |
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/test-results/
|
||||
@@ -91,5 +107,5 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: container-logs-${{ matrix.arch }}
|
||||
name: container-logs-${{ matrix.image_prefix }}
|
||||
path: homeassistant.log
|
||||
|
||||
@@ -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@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.21
|
||||
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:
|
||||
|
||||
@@ -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.*
|
||||
@@ -283,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.*
|
||||
@@ -354,6 +358,7 @@ 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.*
|
||||
@@ -409,6 +414,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.*
|
||||
@@ -623,6 +629,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.*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
+23
-8
@@ -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
|
||||
@@ -649,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
|
||||
@@ -793,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
|
||||
@@ -886,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
|
||||
@@ -899,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
|
||||
@@ -1136,6 +1147,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
|
||||
@@ -1363,8 +1376,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
|
||||
@@ -1717,6 +1730,8 @@ 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
|
||||
@@ -1999,8 +2014,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
|
||||
@@ -2060,6 +2073,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
|
||||
@@ -2128,6 +2142,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "midea",
|
||||
"name": "Midea",
|
||||
"integrations": ["ccm15", "midea"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "vlc",
|
||||
"name": "VideoLAN",
|
||||
"integrations": ["vlc", "vlc_telnet"]
|
||||
}
|
||||
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.2.0"]
|
||||
"requirements": ["aioacaia==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -6,4 +6,3 @@ LOGGER = logging.getLogger(__package__)
|
||||
DOMAIN = "acmeda"
|
||||
|
||||
ACMEDA_HUB_UPDATE = "acmeda_hub_update_{}"
|
||||
ACMEDA_ENTITY_REMOVE = "acmeda_entity_remove_{}"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -60,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(
|
||||
@@ -90,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(
|
||||
@@ -111,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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.2.0"]
|
||||
"requirements": ["aioamazondevices==14.2.2"]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .const import LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
|
||||
from .entity import AmazonEntity
|
||||
|
||||
@@ -239,7 +239,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
|
||||
async def async_set_device_volume(self, volume: int) -> None:
|
||||
"""Set the device volume."""
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Setting volume for %s to %s%%",
|
||||
self.device.serial_number,
|
||||
volume,
|
||||
@@ -278,7 +278,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
self._prev_volume = None
|
||||
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Sending media command '%s' to %s", command, self.device.serial_number
|
||||
)
|
||||
async with alexa_api_call(self.coordinator):
|
||||
|
||||
@@ -44,7 +44,11 @@ def async_get_entry_id_for_service_call(
|
||||
"""Get the entry ID related to a service call (by device ID)."""
|
||||
device_registry = dr.async_get(call.hass)
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
if (device_entry := device_registry.async_get(device_id)) is None:
|
||||
if (
|
||||
device_entry := device_registry.async_get(
|
||||
device_id, include_child_devices=False
|
||||
)
|
||||
) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_device_id",
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{username}",
|
||||
"progress": {
|
||||
"login": "Logging in and scanning for Alexa devices. This can take several minutes if you have many devices linked to your Amazon account."
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.components.todo import (
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
|
||||
from .const import _LOGGER
|
||||
from .const import LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
|
||||
from .entity import AmazonServiceEntity
|
||||
|
||||
@@ -83,7 +83,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
|
||||
super().__init__(coordinator, entity_description)
|
||||
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Created todo entity for list: %s (ID: %s)", self._list.name, self._list.id
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
@override
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Add an item to the To-do list."""
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Creating todo item: %s for list: %s", item.summary, self._list.name
|
||||
)
|
||||
|
||||
@@ -120,7 +120,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
async with alexa_api_call(self.coordinator):
|
||||
await self.coordinator.api.add_todo_list_item(self._list.id, item.summary)
|
||||
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Successfully created todo item: %s for list: %s",
|
||||
item.summary,
|
||||
self._list.name,
|
||||
@@ -129,14 +129,14 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
@override
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Delete items from the to-do list."""
|
||||
_LOGGER.debug("Called async_delete_todo_items for %s item(s)", len(uids))
|
||||
LOGGER.debug("Called async_delete_todo_items for %s item(s)", len(uids))
|
||||
|
||||
list_items_lookup = self.coordinator.todo_list_items[self._list.id]
|
||||
|
||||
for uid in uids:
|
||||
existing_item = list_items_lookup[uid]
|
||||
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Deleting item %s (ID: %s) with version %s",
|
||||
existing_item.name,
|
||||
uid,
|
||||
@@ -146,7 +146,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
await self.coordinator.api.delete_todo_list_item(
|
||||
self._list.id, uid, existing_item.version
|
||||
)
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Successfully deleted item %s (ID: %s) with version %s",
|
||||
existing_item.name,
|
||||
uid,
|
||||
@@ -170,7 +170,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
existing_item.status == AmazonListItemStatus.COMPLETE
|
||||
) != (item.status == TodoItemStatus.COMPLETED):
|
||||
# Update the checked status
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Updating item %s with checked status %s", item.uid, item.status
|
||||
)
|
||||
|
||||
@@ -182,7 +182,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
existing_item.version,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Successfully updated item %s with checked status %s",
|
||||
item.uid,
|
||||
item.status,
|
||||
@@ -190,7 +190,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
|
||||
if existing_item.name != item.summary:
|
||||
# Name has changed, update it
|
||||
_LOGGER.debug("Updating item %s with new name %s", item.uid, item.summary)
|
||||
LOGGER.debug("Updating item %s with new name %s", item.uid, item.summary)
|
||||
|
||||
# If both have changed -> Increase item version by 1
|
||||
version = existing_item.version + int(has_completed_changed)
|
||||
@@ -199,6 +199,6 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
|
||||
await self.coordinator.api.rename_todo_list_item(
|
||||
self._list.id, item.uid, item.summary, version
|
||||
)
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Successfully updated item %s with new name %s", item.uid, item.summary
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .coordinator import AmazonDevicesCoordinator
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ async def async_update_unique_id(
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
platform, DOMAIN, unique_id
|
||||
):
|
||||
_LOGGER.debug("Updating unique_id for %s", entity_id)
|
||||
LOGGER.debug("Updating unique_id for %s", entity_id)
|
||||
new_unique_id = unique_id.replace(old_key, new_key)
|
||||
|
||||
# Update the registry with the new unique_id
|
||||
@@ -52,7 +52,7 @@ async def async_remove_entity_from_virtual_group(
|
||||
is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY
|
||||
if entity_id and is_group:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed entity '%s' from virtual group", entity_id)
|
||||
LOGGER.debug("Removed entity '%s' from virtual group", entity_id)
|
||||
|
||||
|
||||
async def async_remove_unsupported_notification_sensors(
|
||||
@@ -76,4 +76,4 @@ async def async_remove_unsupported_notification_sensors(
|
||||
|
||||
if entity_id and is_unsupported:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed unsupported notification sensor %s", entity_id)
|
||||
LOGGER.debug("Removed unsupported notification sensor %s", entity_id)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["boto3", "botocore", "s3transfer"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["boto3==1.37.1"]
|
||||
"requirements": ["boto3==1.42.97"]
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ class AmbientNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-config-flow-field-not-translated
|
||||
return self.async_show_form(
|
||||
step_id=CONF_USER, data_schema=schema, errors=errors or {}
|
||||
)
|
||||
|
||||
@@ -734,7 +734,36 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
def _device_payload(device_entry: dr.AnyDeviceEntry) -> dict[str, Any]:
|
||||
"""Return the analytics payload for a device or child device."""
|
||||
if isinstance(device_entry, dr.ChildDeviceEntry):
|
||||
# A child device carries no hardware or firmware metadata of its own;
|
||||
# it is reported with its parent referenced as via_device.
|
||||
return {
|
||||
"entry_type": None,
|
||||
"has_configuration_url": False,
|
||||
"hw_version": None,
|
||||
"manufacturer": None,
|
||||
"model": None,
|
||||
"model_id": None,
|
||||
"sw_version": None,
|
||||
"via_device": device_entry.parent_device_id,
|
||||
"entities": [],
|
||||
}
|
||||
return {
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
"entities": [],
|
||||
}
|
||||
|
||||
|
||||
async def _async_snapshot_payload(hass: HomeAssistant) -> dict:
|
||||
"""Return detailed information about entities and devices for a snapshot."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
@@ -745,18 +774,17 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
removed_devices: set[str] = set()
|
||||
|
||||
# Get device list
|
||||
for device_entry in dev_reg.devices.values():
|
||||
if not device_entry.primary_config_entry:
|
||||
continue
|
||||
|
||||
config_entry = hass.config_entries.async_get_entry(
|
||||
device_entry.primary_config_entry
|
||||
)
|
||||
for device_entry in (*dev_reg.devices.values(), *dev_reg.child_devices.values()):
|
||||
config_entry = hass.config_entries.async_get_entry(device_entry.config_entry_id)
|
||||
|
||||
if config_entry is None:
|
||||
continue
|
||||
|
||||
if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
|
||||
# Only full devices can be service devices; child devices never are.
|
||||
if (
|
||||
isinstance(device_entry, dr.DeviceEntry)
|
||||
and device_entry.entry_type is dr.DeviceEntryType.SERVICE
|
||||
):
|
||||
removed_devices.add(device_entry.id)
|
||||
continue
|
||||
|
||||
@@ -854,23 +882,12 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
removed_devices.add(device_id)
|
||||
continue
|
||||
|
||||
device_entry = dev_reg.devices[device_id]
|
||||
resolved_device = dev_reg.async_get(device_id)
|
||||
assert resolved_device is not None
|
||||
|
||||
device_id_mapping[device_id] = (integration_domain, len(devices_info))
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
"entities": [],
|
||||
}
|
||||
)
|
||||
devices_info.append(_device_payload(resolved_device))
|
||||
|
||||
# Fill out via_device with new device ids
|
||||
for integration_info in integrations_info.values():
|
||||
|
||||
@@ -35,8 +35,8 @@ async def async_get_config_entry_diagnostics(
|
||||
# Gather information how this AndroidTV device is represented in Home Assistant
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
hass_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, str(entry.unique_id))}
|
||||
hass_device = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, str(entry.unique_id)), entry.entry_id
|
||||
)
|
||||
if not hass_device:
|
||||
return data
|
||||
|
||||
@@ -6,6 +6,7 @@ from pyanglianwater.auth import MSOB2CAuth
|
||||
from pyanglianwater.exceptions import (
|
||||
ConsentRequiredError,
|
||||
ExpiredAccessTokenError,
|
||||
InvalidGrantError,
|
||||
SelfAssertedError,
|
||||
SmartMeterUnavailableError,
|
||||
)
|
||||
@@ -17,11 +18,16 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
|
||||
from .helpers import async_create_consent_required_issue
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
@@ -41,8 +47,17 @@ async def async_setup_entry(
|
||||
)
|
||||
try:
|
||||
await auth.send_refresh_request()
|
||||
except (ConsentRequiredError, ExpiredAccessTokenError, SelfAssertedError) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except ConsentRequiredError as err:
|
||||
async_create_consent_required_issue(hass, entry.data[CONF_ACCOUNT_NUMBER])
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="consent_required",
|
||||
) from err
|
||||
except (ExpiredAccessTokenError, InvalidGrantError, SelfAssertedError) as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_expired",
|
||||
) from err
|
||||
|
||||
_aw = AnglianWater(authenticator=auth)
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
|
||||
"""Validate the provided credentials."""
|
||||
try:
|
||||
await auth.send_login_request()
|
||||
except ConsentRequiredError, SelfAssertedError:
|
||||
except ConsentRequiredError:
|
||||
return "consent_required"
|
||||
except SelfAssertedError:
|
||||
return "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
|
||||
@@ -8,6 +8,7 @@ from pyanglianwater import AnglianWater
|
||||
from pyanglianwater.exceptions import (
|
||||
ConsentRequiredError,
|
||||
ExpiredAccessTokenError,
|
||||
InvalidGrantError,
|
||||
UnknownEndpointError,
|
||||
)
|
||||
|
||||
@@ -25,11 +26,16 @@ from homeassistant.components.recorder.statistics import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import VolumeConverter
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
from .helpers import (
|
||||
async_create_consent_required_issue,
|
||||
async_delete_consent_required_issue,
|
||||
)
|
||||
|
||||
type AnglianWaterConfigEntry = ConfigEntry[AnglianWaterUpdateCoordinator]
|
||||
|
||||
@@ -64,12 +70,30 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
try:
|
||||
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
|
||||
await self._insert_statistics()
|
||||
except (
|
||||
ConsentRequiredError,
|
||||
ExpiredAccessTokenError,
|
||||
UnknownEndpointError,
|
||||
) as err:
|
||||
raise UpdateFailed from err
|
||||
except ConsentRequiredError as err:
|
||||
async_create_consent_required_issue(
|
||||
self.hass, self.config_entry.data[CONF_ACCOUNT_NUMBER]
|
||||
)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="consent_required",
|
||||
retry_after=900.0,
|
||||
) from err
|
||||
except (ExpiredAccessTokenError, InvalidGrantError) as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_expired",
|
||||
) from err
|
||||
except UnknownEndpointError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_unavailable",
|
||||
retry_after=60.0,
|
||||
) from err
|
||||
else:
|
||||
async_delete_consent_required_issue(
|
||||
self.hass, self.config_entry.data[CONF_ACCOUNT_NUMBER]
|
||||
)
|
||||
|
||||
async def _insert_statistics(self) -> None:
|
||||
"""Insert statistics for water meters into Home Assistant."""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Helpers for the Anglian Water integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
|
||||
LEARN_MORE_URL = "https://myaccount.anglianwater.co.uk/"
|
||||
|
||||
|
||||
def consent_required_issue_id(account_number: str) -> str:
|
||||
"""Return the repair issue id for a consent-required condition."""
|
||||
return f"consent_required_{account_number}"
|
||||
|
||||
|
||||
def async_create_consent_required_issue(
|
||||
hass: HomeAssistant, account_number: str
|
||||
) -> None:
|
||||
"""Create a repair issue directing the user to accept updated terms."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
consent_required_issue_id(account_number),
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="consent_required",
|
||||
translation_placeholders={
|
||||
CONF_ACCOUNT_NUMBER: account_number,
|
||||
},
|
||||
learn_more_url=LEARN_MORE_URL,
|
||||
)
|
||||
|
||||
|
||||
def async_delete_consent_required_issue(
|
||||
hass: HomeAssistant, account_number: str
|
||||
) -> None:
|
||||
"""Delete the consent-required repair issue if it exists."""
|
||||
ir.async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
consent_required_issue_id(account_number),
|
||||
)
|
||||
@@ -44,7 +44,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
@@ -78,10 +78,7 @@ rules:
|
||||
comment: |
|
||||
Entities do not require different icons.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
Read-only integration and no repairs are possible.
|
||||
repair-issues: done
|
||||
stale-devices: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"consent_required": "You need to accept the terms and conditions for your Anglian Water account before using this integration. Log in to their website for further information.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"smart_meter_unavailable": "This account does not have any smart meters associated with it. If this is unexpected, enter your Billing Account Number found at the top of your latest bill.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
@@ -55,11 +56,20 @@
|
||||
"auth_expired": {
|
||||
"message": "Authentication token expired"
|
||||
},
|
||||
"consent_required": {
|
||||
"message": "The terms and conditions for your Anglian Water account have been updated. Please visit and log in to your Anglian Water account to accept the new terms and conditions."
|
||||
},
|
||||
"service_unavailable": {
|
||||
"message": "Anglian Water services are currently unavailable for maintenance."
|
||||
"message": "Anglian Water web services are currently unavailable, check the logs for more details."
|
||||
},
|
||||
"smart_meter_unavailable": {
|
||||
"message": "This account no longer has a smart meter associated with it."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"consent_required": {
|
||||
"description": "The terms and conditions for account {account_number} have been updated. Please visit and log in to your Anglian Water account to accept the new terms and conditions.\n\nThis issue will automatically resolve once the new conditions have been accepted and on the next refresh cycle.",
|
||||
"title": "Anglian Water terms and conditions updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["anova_wifi"],
|
||||
"requirements": ["anova-wifi==0.17.1"]
|
||||
"requirements": ["anova-wifi==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -6,12 +6,21 @@ import anthemav
|
||||
from anthemav.device_error import DeviceError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS
|
||||
from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS, DOMAIN, MANUFACTURER
|
||||
|
||||
type AnthemavConfigEntry = ConfigEntry[anthemav.Connection]
|
||||
|
||||
@@ -43,6 +52,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthemavConfigEntry) ->
|
||||
|
||||
entry.runtime_data = avr
|
||||
|
||||
# Register the zone 1 receiver first so higher zones can link to it as a
|
||||
# via device when their entities are created.
|
||||
mac_address = entry.data[CONF_MAC]
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, mac_address)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac_address)},
|
||||
name=entry.title,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=entry.data[CONF_MODEL],
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@callback
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user