mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Merge branch 'dev' into add-librenms-integration
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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,6 +150,7 @@ 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.
|
||||
@@ -162,7 +164,7 @@ 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`.
|
||||
- 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,14 @@ 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
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 +102,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 +130,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"
|
||||
|
||||
@@ -245,7 +245,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
|
||||
|
||||
@@ -292,7 +292,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -342,13 +342,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -469,12 +469,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 +497,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@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -516,12 +516,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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -550,7 +550,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@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -37,11 +37,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
|
||||
|
||||
+6
-6
@@ -31,10 +31,10 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
|
||||
#
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -403,7 +403,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Create gh-aw temp directory
|
||||
@@ -1234,7 +1234,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1301,7 +1301,7 @@ jobs:
|
||||
mkdir -p /tmp/gh-aw/threat-detection
|
||||
touch /tmp/gh-aw/threat-detection/detection.log
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
|
||||
+61
-24
@@ -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
|
||||
@@ -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@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
|
||||
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@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
|
||||
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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -44,7 +44,7 @@ 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
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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.0
|
||||
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.28.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.*
|
||||
@@ -409,6 +410,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 +625,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,6 +13,7 @@ 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.
|
||||
@@ -26,7 +27,7 @@ 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`.
|
||||
- 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,14 @@ 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.
|
||||
|
||||
## 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
+12
-6
@@ -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
|
||||
@@ -649,6 +651,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
|
||||
@@ -886,8 +890,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 +903,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 +1141,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/metoffice/ @MrHarcombe @avee87
|
||||
/homeassistant/components/microbees/ @microBeesTech
|
||||
/tests/components/microbees/ @microBeesTech
|
||||
/homeassistant/components/midea/ @chemelli74 @rokam @wuwentao
|
||||
/tests/components/midea/ @chemelli74 @rokam @wuwentao
|
||||
/homeassistant/components/miele/ @astrandb
|
||||
/tests/components/miele/ @astrandb
|
||||
/homeassistant/components/mikrotik/ @engrbm87 @chemelli74
|
||||
@@ -1363,8 +1370,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
|
||||
@@ -1999,8 +2006,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 +2065,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -675,7 +675,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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .coordinator import (
|
||||
ActronAirConfigEntry,
|
||||
ActronAirRuntimeData,
|
||||
@@ -40,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
||||
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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,7 +18,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)
|
||||
@@ -50,7 +50,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
LOGGER,
|
||||
name="Actron Air Status",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=entry,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -45,7 +45,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 +283,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."""
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx
|
||||
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
|
||||
|
||||
@@ -92,9 +92,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 +106,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()
|
||||
|
||||
@@ -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,12 +232,12 @@ 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(
|
||||
@@ -259,7 +259,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 +281,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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
entry.entry_id,
|
||||
)
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
device = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, entry.entry_id), entry.entry_id
|
||||
)
|
||||
|
||||
if conversation_entity_id is not None:
|
||||
|
||||
@@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AprilaireConfigEntry) ->
|
||||
host = entry.data[CONF_HOST]
|
||||
port = entry.data[CONF_PORT]
|
||||
|
||||
coordinator = AprilaireCoordinator(hass, entry.unique_id, host, port)
|
||||
coordinator = AprilaireCoordinator(hass, entry, host, port)
|
||||
await coordinator.start_listen()
|
||||
|
||||
async def ready_callback(ready: bool) -> None:
|
||||
|
||||
@@ -30,14 +30,15 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
unique_id: str | None,
|
||||
config_entry: AprilaireConfigEntry | None,
|
||||
host: str,
|
||||
port: int,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
|
||||
self.hass = hass
|
||||
self.unique_id = unique_id
|
||||
self.config_entry = config_entry
|
||||
self.unique_id = config_entry.unique_id if config_entry else None
|
||||
self.data: dict[str, Any] = {}
|
||||
|
||||
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
|
||||
@@ -88,13 +89,16 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
new_device_info = self.create_device_info(data)
|
||||
|
||||
if (
|
||||
old_device_info is not None
|
||||
self.config_entry is not None
|
||||
and old_device_info is not None
|
||||
and new_device_info is not None
|
||||
and old_device_info != new_device_info
|
||||
):
|
||||
device_registry = dr.async_get(self.hass)
|
||||
|
||||
device = device_registry.async_get_device(old_device_info["identifiers"])
|
||||
device = device_registry.async_get_device_by_identifier(
|
||||
next(iter(old_device_info["identifiers"])), self.config_entry.entry_id
|
||||
)
|
||||
|
||||
if device is not None:
|
||||
new_device_info.pop("identifiers", None)
|
||||
|
||||
@@ -126,8 +126,9 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
|
||||
account_id = self.config_entry.unique_id
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in stale_devices:
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{account_id}_{device_id}")}
|
||||
device = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, f"{account_id}_{device_id}"),
|
||||
self.config_entry.entry_id,
|
||||
)
|
||||
if device:
|
||||
device_registry.async_update_device(
|
||||
|
||||
@@ -41,7 +41,7 @@ async def async_setup_platform(
|
||||
try:
|
||||
event = json_loads_object(msg.payload)
|
||||
device = parse_message(msg.topic, event)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
_LOGGER.debug(
|
||||
"Failed to parse ARWN message on topic %s",
|
||||
msg.topic,
|
||||
|
||||
@@ -342,13 +342,13 @@ async def async_update_pipeline(
|
||||
conversation_language: str | UndefinedType = UNDEFINED,
|
||||
language: str | UndefinedType = UNDEFINED,
|
||||
name: str | UndefinedType = UNDEFINED,
|
||||
stt_engine: str | None | UndefinedType = UNDEFINED,
|
||||
stt_language: str | None | UndefinedType = UNDEFINED,
|
||||
tts_engine: str | None | UndefinedType = UNDEFINED,
|
||||
tts_language: str | None | UndefinedType = UNDEFINED,
|
||||
tts_voice: str | None | UndefinedType = UNDEFINED,
|
||||
wake_word_entity: str | None | UndefinedType = UNDEFINED,
|
||||
wake_word_id: str | None | UndefinedType = UNDEFINED,
|
||||
stt_engine: str | UndefinedType | None = UNDEFINED,
|
||||
stt_language: str | UndefinedType | None = UNDEFINED,
|
||||
tts_engine: str | UndefinedType | None = UNDEFINED,
|
||||
tts_language: str | UndefinedType | None = UNDEFINED,
|
||||
tts_voice: str | UndefinedType | None = UNDEFINED,
|
||||
wake_word_entity: str | UndefinedType | None = UNDEFINED,
|
||||
wake_word_id: str | UndefinedType | None = UNDEFINED,
|
||||
prefer_local_intents: bool | UndefinedType = UNDEFINED,
|
||||
) -> None:
|
||||
"""Update a pipeline."""
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.8.0"]
|
||||
"requirements": ["hassil==3.10.0"]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import AsusWrtConfigEntry
|
||||
from .router import get_device_identifier
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME}
|
||||
TO_REDACT_DEV = {ATTR_CONNECTIONS, ATTR_IDENTIFIERS}
|
||||
@@ -34,8 +35,8 @@ async def async_get_config_entry_diagnostics(
|
||||
# Gather information how this AsusWrt 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=router.device_info[ATTR_IDENTIFIERS]
|
||||
hass_device = device_registry.async_get_device_by_identifier(
|
||||
get_device_identifier(entry), entry.entry_id
|
||||
)
|
||||
if not hass_device:
|
||||
return data
|
||||
|
||||
@@ -53,6 +53,12 @@ SENSORS_TYPE_COUNT = "sensors_count"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_device_identifier(entry: ConfigEntry) -> tuple[str, str]:
|
||||
"""Return the device registry identifier of the router."""
|
||||
return (DOMAIN, entry.unique_id or "AsusWRT")
|
||||
|
||||
|
||||
_ENTITY_MIGRATION_ID = {
|
||||
"sensor_connected_device": "Devices Connected",
|
||||
"sensor_rx_bytes": "Download",
|
||||
@@ -389,7 +395,7 @@ class AsusWrtRouter:
|
||||
"""Return the device information."""
|
||||
info = DeviceInfo(
|
||||
configuration_url=self._api.configuration_url,
|
||||
identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")},
|
||||
identifiers={get_device_identifier(self._entry)},
|
||||
name=self.host,
|
||||
model=self._api.model or "Asus Router",
|
||||
model_id=self._api.model_id,
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.1"]
|
||||
"requirements": ["yalexs==9.2.10", "yalexs-ble==3.3.1"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiobotocore", "botocore"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"]
|
||||
"requirements": ["aiobotocore==3.7.0"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiobotocore"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiobotocore==2.21.1"]
|
||||
"requirements": ["aiobotocore==3.7.0"]
|
||||
}
|
||||
|
||||
@@ -52,16 +52,25 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
return f"{base_name}.tar", f"{base_name}.metadata.json"
|
||||
|
||||
|
||||
# Other tools may write unrelated files ending in `.metadata.json` into the
|
||||
# bucket; such files must be skipped rather than crashing the backup listing.
|
||||
REQUIRED_METADATA_KEYS = frozenset({"metadata_version", "backup_id", "backup_metadata"})
|
||||
|
||||
|
||||
def _parse_metadata(raw_content: str) -> dict[str, Any]:
|
||||
"""Parse metadata content from JSON."""
|
||||
"""Parse metadata content from JSON and validate its schema."""
|
||||
try:
|
||||
data = json.loads(raw_content)
|
||||
except json.JSONDecodeError as err:
|
||||
raise ValueError(f"Invalid JSON format: {err}") from err
|
||||
else:
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("JSON content is not a dictionary")
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("JSON content is not a dictionary")
|
||||
missing = REQUIRED_METADATA_KEYS - data.keys()
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Missing required metadata keys: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _find_backup_file_for_metadata(
|
||||
@@ -557,7 +566,13 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
metadata_content = _parse_metadata(
|
||||
download_response.content.decode("utf-8")
|
||||
)
|
||||
except ValueError:
|
||||
except (TypeError, ValueError) as err:
|
||||
_LOGGER.warning(
|
||||
"Skipping metadata file %s: not a valid Backblaze B2 backup "
|
||||
"metadata file (%s)",
|
||||
file_name,
|
||||
err,
|
||||
)
|
||||
return None, None
|
||||
|
||||
if metadata_content["backup_id"] != target_backup_id:
|
||||
@@ -633,7 +648,13 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
metadata_content = _parse_metadata(
|
||||
download_response.content.decode("utf-8")
|
||||
)
|
||||
except ValueError:
|
||||
except (TypeError, ValueError) as err:
|
||||
_LOGGER.warning(
|
||||
"Skipping metadata file %s: not a valid Backblaze B2 backup "
|
||||
"metadata file (%s)",
|
||||
file_name,
|
||||
err,
|
||||
)
|
||||
return None
|
||||
|
||||
found_backup_file = _find_backup_file_for_metadata(
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"backup_manager_state": {
|
||||
"name": "Backup Manager state",
|
||||
"state": {
|
||||
"blocked": "Blocked",
|
||||
"create_backup": "Creating a backup",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"receive_backup": "Receiving a backup",
|
||||
|
||||
@@ -76,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
# Create device now as BeoWebsocket needs a device for
|
||||
# debug logging, firing events etc.
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.unique_id)},
|
||||
model=entry.data[CONF_MODEL],
|
||||
@@ -92,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
serial_number=remote.serial_number,
|
||||
sw_version=remote.app_version,
|
||||
manufacturer=MANUFACTURER,
|
||||
via_device=(DOMAIN, entry.unique_id),
|
||||
via_device_id=device.id,
|
||||
)
|
||||
|
||||
websocket = BeoWebsocket(hass, entry, client)
|
||||
|
||||
@@ -21,10 +21,12 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
|
||||
def get_device(hass: HomeAssistant, unique_id: str, entry_id: str) -> DeviceEntry:
|
||||
"""Get the device."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get_device({(DOMAIN, unique_id)})
|
||||
device = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, unique_id), entry_id
|
||||
)
|
||||
assert device
|
||||
|
||||
return device
|
||||
|
||||
@@ -50,7 +50,7 @@ class BeoWebsocket(BeoBase):
|
||||
BeoBase.__init__(self, entry, client)
|
||||
|
||||
self.hass = hass
|
||||
self._device = get_device(hass, self._unique_id)
|
||||
self._device = get_device(hass, self._unique_id, self.entry.entry_id)
|
||||
|
||||
# WebSocket callbacks
|
||||
self._client.get_notification_notifications(self.on_notification_notification)
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["blinkpy"],
|
||||
"requirements": ["blinkpy==0.25.6"]
|
||||
"requirements": ["blinkpy==0.25.9"]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==3.0.2",
|
||||
"bleak-retry-connector==4.6.1",
|
||||
"bleak-retry-connector==4.6.3",
|
||||
"bluetooth-adapters==2.4.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
|
||||
@@ -120,10 +120,10 @@ def _async_remove_old_device_identifiers(
|
||||
) -> None:
|
||||
"""Remove the non-unique device registry entries."""
|
||||
for device in hub.devices:
|
||||
dev = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)})
|
||||
if dev is None:
|
||||
continue
|
||||
if config_entry_id in dev.config_entries:
|
||||
dev = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, device.device_id), config_entry_id
|
||||
)
|
||||
if dev is not None:
|
||||
device_registry.async_remove_device(dev.id)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The Bosch Smart Home Controller integration."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from boschshcpy import SHCSession
|
||||
from boschshcpy.exceptions import SHCAuthenticationError, SHCConnectionError
|
||||
@@ -47,6 +48,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> boo
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
shc_info = session.information
|
||||
if TYPE_CHECKING:
|
||||
assert shc_info is not None and shc_info.unique_id is not None
|
||||
if shc_info.updateState.name == "UPDATE_AVAILABLE":
|
||||
_LOGGER.warning("Please check for software updates in the Bosch Smart Home App")
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Platform for binarysensor integration."""
|
||||
|
||||
from typing import override
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from boschshcpy import SHCBatteryDevice, SHCShutterContact
|
||||
from boschshcpy import (
|
||||
BatteryLevelService,
|
||||
SHCBatteryDevice,
|
||||
SHCShutterContact,
|
||||
ShutterContactService,
|
||||
)
|
||||
from boschshcpy.device import SHCDevice
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -15,6 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import BoschConfigEntry
|
||||
from .entity import SHCEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -24,34 +31,38 @@ async def async_setup_entry(
|
||||
"""Set up the SHC binary sensor platform."""
|
||||
session = config_entry.runtime_data
|
||||
|
||||
shc_info = session.information
|
||||
if TYPE_CHECKING:
|
||||
assert shc_info is not None and shc_info.unique_id is not None
|
||||
|
||||
entities: list[BinarySensorEntity] = [
|
||||
ShutterContactSensor(
|
||||
device=binary_sensor,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
)
|
||||
for binary_sensor in (
|
||||
session.device_helper.shutter_contacts
|
||||
+ session.device_helper.shutter_contacts2
|
||||
*session.device_helper.shutter_contacts,
|
||||
*session.device_helper.shutter_contacts2,
|
||||
)
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
BatterySensor(
|
||||
device=binary_sensor,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
)
|
||||
for binary_sensor in (
|
||||
session.device_helper.motion_detectors
|
||||
+ session.device_helper.shutter_contacts
|
||||
+ session.device_helper.shutter_contacts2
|
||||
+ session.device_helper.smoke_detectors
|
||||
+ session.device_helper.thermostats
|
||||
+ session.device_helper.twinguards
|
||||
+ session.device_helper.universal_switches
|
||||
+ session.device_helper.wallthermostats
|
||||
+ session.device_helper.water_leakage_detectors
|
||||
*session.device_helper.motion_detectors,
|
||||
*session.device_helper.shutter_contacts,
|
||||
*session.device_helper.shutter_contacts2,
|
||||
*session.device_helper.smoke_detectors,
|
||||
*session.device_helper.thermostats,
|
||||
*session.device_helper.twinguards,
|
||||
*session.device_helper.universal_switches,
|
||||
*session.device_helper.wallthermostats,
|
||||
*session.device_helper.water_leakage_detectors,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -62,11 +73,12 @@ class ShutterContactSensor(SHCEntity, BinarySensorEntity):
|
||||
"""Representation of an SHC shutter contact sensor."""
|
||||
|
||||
_attr_name = None
|
||||
_device: SHCShutterContact
|
||||
|
||||
def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
|
||||
"""Initialize an SHC shutter contact sensor.."""
|
||||
"""Initialize an SHC shutter contact sensor."""
|
||||
super().__init__(device, parent_id, entry_id)
|
||||
switcher = {
|
||||
switcher: dict[str | None, BinarySensorDeviceClass] = {
|
||||
"ENTRANCE_DOOR": BinarySensorDeviceClass.DOOR,
|
||||
"REGULAR_WINDOW": BinarySensorDeviceClass.WINDOW,
|
||||
"FRENCH_WINDOW": BinarySensorDeviceClass.DOOR,
|
||||
@@ -80,13 +92,14 @@ class ShutterContactSensor(SHCEntity, BinarySensorEntity):
|
||||
@override
|
||||
def is_on(self) -> bool:
|
||||
"""Return the state of the sensor."""
|
||||
return self._device.state == SHCShutterContact.ShutterContactService.State.OPEN
|
||||
return self._device.state is ShutterContactService.State.OPEN
|
||||
|
||||
|
||||
class BatterySensor(SHCEntity, BinarySensorEntity):
|
||||
"""Representation of an SHC battery reporting sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY
|
||||
_device: SHCBatteryDevice
|
||||
|
||||
def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
|
||||
"""Initialize an SHC battery reporting sensor."""
|
||||
@@ -97,6 +110,4 @@ class BatterySensor(SHCEntity, BinarySensorEntity):
|
||||
@override
|
||||
def is_on(self) -> bool:
|
||||
"""Return the state of the sensor."""
|
||||
return (
|
||||
self._device.batterylevel != SHCBatteryDevice.BatteryLevelService.State.OK
|
||||
)
|
||||
return self._device.batterylevel is not BatteryLevelService.State.OK
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Platform for cover integration."""
|
||||
|
||||
from typing import Any, override
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from boschshcpy import SHCShutterControl
|
||||
from boschshcpy import SHCShutterControl, ShutterControlService
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
@@ -16,6 +16,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import BoschConfigEntry
|
||||
from .entity import SHCEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -25,10 +27,14 @@ async def async_setup_entry(
|
||||
"""Set up the SHC cover platform."""
|
||||
session = config_entry.runtime_data
|
||||
|
||||
shc_info = session.information
|
||||
if TYPE_CHECKING:
|
||||
assert shc_info is not None and shc_info.unique_id is not None
|
||||
|
||||
async_add_entities(
|
||||
ShutterControlCover(
|
||||
device=cover,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
)
|
||||
for cover in session.device_helper.shutter_controls
|
||||
@@ -40,6 +46,7 @@ class ShutterControlCover(SHCEntity, CoverEntity):
|
||||
|
||||
_attr_name = None
|
||||
_attr_device_class = CoverDeviceClass.SHUTTER
|
||||
_device: SHCShutterControl
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
@@ -68,19 +75,13 @@ class ShutterControlCover(SHCEntity, CoverEntity):
|
||||
@override
|
||||
def is_opening(self) -> bool:
|
||||
"""Return if the cover is opening or not."""
|
||||
return (
|
||||
self._device.operation_state
|
||||
== SHCShutterControl.ShutterControlService.State.OPENING
|
||||
)
|
||||
return self._device.operation_state is ShutterControlService.State.OPENING
|
||||
|
||||
@property
|
||||
@override
|
||||
def is_closing(self) -> bool:
|
||||
"""Return if the cover is closing or not."""
|
||||
return (
|
||||
self._device.operation_state
|
||||
== SHCShutterControl.ShutterControlService.State.CLOSING
|
||||
)
|
||||
return self._device.operation_state is ShutterControlService.State.CLOSING
|
||||
|
||||
@override
|
||||
def open_cover(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -17,7 +17,9 @@ async def async_remove_devices(
|
||||
) -> None:
|
||||
"""Get item that is removed from session."""
|
||||
dev_registry = dr.async_get(hass)
|
||||
device = dev_registry.async_get_device(identifiers={(DOMAIN, entity.device_id)})
|
||||
device = dev_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, entity.device_id), entry_id
|
||||
)
|
||||
if device is not None:
|
||||
dev_registry.async_update_device(device.id, remove_config_entry_id=entry_id)
|
||||
|
||||
@@ -63,6 +65,8 @@ class SHCBaseEntity(Entity):
|
||||
class SHCEntity(SHCBaseEntity):
|
||||
"""Representation of a SHC device entity."""
|
||||
|
||||
_device: SHCDevice
|
||||
|
||||
def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
|
||||
"""Initialize generic SHC device."""
|
||||
self._attr_unique_id = device.serial
|
||||
@@ -103,6 +107,8 @@ class SHCEntity(SHCBaseEntity):
|
||||
class SHCDomainEntity(SHCBaseEntity):
|
||||
"""Representation of a SHC domain service entity."""
|
||||
|
||||
_device: SHCIntrusionSystem
|
||||
|
||||
def __init__(
|
||||
self, domain: SHCIntrusionSystem, parent_id: str, entry_id: str
|
||||
) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["boschshcpy"],
|
||||
"requirements": ["boschshcpy==0.3.5"],
|
||||
"requirements": ["boschshcpy==0.6.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bosch shc*",
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from boschshcpy import (
|
||||
SHCLightSwitchBSM,
|
||||
SHCSmartPlug,
|
||||
SHCSmartPlugCompact,
|
||||
SHCThermostat,
|
||||
SHCTwinguard,
|
||||
SHCWallThermostat,
|
||||
)
|
||||
from boschshcpy.device import SHCDevice
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -25,14 +33,21 @@ from homeassistant.helpers.typing import StateType
|
||||
from . import BoschConfigEntry
|
||||
from .entity import SHCEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SHCSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a SHC sensor."""
|
||||
class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription):
|
||||
"""Describes a SHC sensor.
|
||||
|
||||
value_fn: Callable[[SHCDevice], StateType]
|
||||
attributes_fn: Callable[[SHCDevice], dict[str, Any]] | None = None
|
||||
Never share one instance across descriptions for different device types.
|
||||
"""
|
||||
|
||||
value_fn: Callable[[_DeviceT], StateType]
|
||||
attributes_fn: Callable[[_DeviceT], dict[str, Any]] | None = None
|
||||
|
||||
|
||||
_PowerMeterDevice = SHCSmartPlug | SHCLightSwitchBSM
|
||||
|
||||
TEMPERATURE_SENSOR = "temperature"
|
||||
HUMIDITY_SENSOR = "humidity"
|
||||
@@ -46,68 +61,17 @@ POWER_SENSOR = "power"
|
||||
ENERGY_SENSOR = "energy"
|
||||
COMMUNICATION_QUALITY_SENSOR = "communication_quality"
|
||||
|
||||
SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = {
|
||||
TEMPERATURE_SENSOR: SHCSensorEntityDescription(
|
||||
_THERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=TEMPERATURE_SENSOR,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda device: device.temperature,
|
||||
),
|
||||
HUMIDITY_SENSOR: SHCSensorEntityDescription(
|
||||
key=HUMIDITY_SENSOR,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
|
||||
value_fn=lambda device: device.humidity,
|
||||
),
|
||||
PURITY_SENSOR: SHCSensorEntityDescription(
|
||||
key=PURITY_SENSOR,
|
||||
translation_key=PURITY_SENSOR,
|
||||
native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION,
|
||||
value_fn=lambda device: device.purity,
|
||||
),
|
||||
AIR_QUALITY_SENSOR: SHCSensorEntityDescription(
|
||||
key=AIR_QUALITY_SENSOR,
|
||||
translation_key="air_quality",
|
||||
value_fn=lambda device: device.combined_rating.name,
|
||||
attributes_fn=lambda device: {
|
||||
"rating_description": device.description,
|
||||
},
|
||||
),
|
||||
TEMPERATURE_RATING_SENSOR: SHCSensorEntityDescription(
|
||||
key=TEMPERATURE_RATING_SENSOR,
|
||||
translation_key=TEMPERATURE_RATING_SENSOR,
|
||||
value_fn=lambda device: device.temperature_rating.name,
|
||||
),
|
||||
COMMUNICATION_QUALITY_SENSOR: SHCSensorEntityDescription(
|
||||
key=COMMUNICATION_QUALITY_SENSOR,
|
||||
translation_key=COMMUNICATION_QUALITY_SENSOR,
|
||||
value_fn=lambda device: device.communicationquality.name,
|
||||
),
|
||||
HUMIDITY_RATING_SENSOR: SHCSensorEntityDescription(
|
||||
key=HUMIDITY_RATING_SENSOR,
|
||||
translation_key=HUMIDITY_RATING_SENSOR,
|
||||
value_fn=lambda device: device.humidity_rating.name,
|
||||
),
|
||||
PURITY_RATING_SENSOR: SHCSensorEntityDescription(
|
||||
key=PURITY_RATING_SENSOR,
|
||||
translation_key=PURITY_RATING_SENSOR,
|
||||
value_fn=lambda device: device.purity_rating.name,
|
||||
),
|
||||
POWER_SENSOR: SHCSensorEntityDescription(
|
||||
key=POWER_SENSOR,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
value_fn=lambda device: device.powerconsumption,
|
||||
),
|
||||
ENERGY_SENSOR: SHCSensorEntityDescription(
|
||||
key=ENERGY_SENSOR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_fn=lambda device: device.energyconsumption / 1000.0,
|
||||
),
|
||||
VALVE_TAPPET_SENSOR: SHCSensorEntityDescription(
|
||||
)
|
||||
)
|
||||
_VALVE_TAPPET_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=VALVE_TAPPET_SENSOR,
|
||||
translation_key=VALVE_TAPPET_SENSOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -116,8 +80,122 @@ SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = {
|
||||
attributes_fn=lambda device: {
|
||||
"valve_tappet_state": device.valvestate.name,
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
_WALLTHERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[
|
||||
SHCWallThermostat
|
||||
] = SHCSensorEntityDescription(
|
||||
key=TEMPERATURE_SENSOR,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda device: device.temperature,
|
||||
)
|
||||
_WALLTHERMOSTAT_HUMIDITY_DESCRIPTION: SHCSensorEntityDescription[SHCWallThermostat] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=HUMIDITY_SENSOR,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
|
||||
value_fn=lambda device: device.humidity,
|
||||
)
|
||||
)
|
||||
_TWINGUARD_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=TEMPERATURE_SENSOR,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda device: device.temperature,
|
||||
)
|
||||
)
|
||||
_TWINGUARD_HUMIDITY_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=HUMIDITY_SENSOR,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
|
||||
value_fn=lambda device: device.humidity,
|
||||
)
|
||||
)
|
||||
_PURITY_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=PURITY_SENSOR,
|
||||
translation_key=PURITY_SENSOR,
|
||||
native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION,
|
||||
value_fn=lambda device: device.purity,
|
||||
)
|
||||
)
|
||||
_AIR_QUALITY_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=AIR_QUALITY_SENSOR,
|
||||
translation_key="air_quality",
|
||||
value_fn=lambda device: device.combined_rating.name,
|
||||
attributes_fn=lambda device: {
|
||||
"rating_description": device.description,
|
||||
},
|
||||
)
|
||||
)
|
||||
_TEMPERATURE_RATING_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=TEMPERATURE_RATING_SENSOR,
|
||||
translation_key=TEMPERATURE_RATING_SENSOR,
|
||||
value_fn=lambda device: device.temperature_rating.name,
|
||||
)
|
||||
)
|
||||
_HUMIDITY_RATING_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=HUMIDITY_RATING_SENSOR,
|
||||
translation_key=HUMIDITY_RATING_SENSOR,
|
||||
value_fn=lambda device: device.humidity_rating.name,
|
||||
)
|
||||
)
|
||||
_PURITY_RATING_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=PURITY_RATING_SENSOR,
|
||||
translation_key=PURITY_RATING_SENSOR,
|
||||
value_fn=lambda device: device.purity_rating.name,
|
||||
)
|
||||
)
|
||||
_POWER_DESCRIPTION: SHCSensorEntityDescription[_PowerMeterDevice] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=POWER_SENSOR,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
value_fn=lambda device: device.powerconsumption,
|
||||
)
|
||||
)
|
||||
_ENERGY_DESCRIPTION: SHCSensorEntityDescription[_PowerMeterDevice] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=ENERGY_SENSOR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_fn=lambda device: device.energyconsumption / 1000.0,
|
||||
)
|
||||
)
|
||||
_COMPACT_POWER_DESCRIPTION: SHCSensorEntityDescription[SHCSmartPlugCompact] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=POWER_SENSOR,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
value_fn=lambda device: device.powerconsumption,
|
||||
)
|
||||
)
|
||||
_COMPACT_ENERGY_DESCRIPTION: SHCSensorEntityDescription[SHCSmartPlugCompact] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=ENERGY_SENSOR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_fn=lambda device: device.energyconsumption / 1000.0,
|
||||
)
|
||||
)
|
||||
_COMMUNICATION_QUALITY_DESCRIPTION: SHCSensorEntityDescription[SHCSmartPlugCompact] = (
|
||||
SHCSensorEntityDescription(
|
||||
key=COMMUNICATION_QUALITY_SENSOR,
|
||||
translation_key=COMMUNICATION_QUALITY_SENSOR,
|
||||
value_fn=lambda device: device.communicationquality.name,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -128,88 +206,105 @@ async def async_setup_entry(
|
||||
"""Set up the SHC sensor platform."""
|
||||
session = config_entry.runtime_data
|
||||
|
||||
shc_info = session.information
|
||||
if TYPE_CHECKING:
|
||||
assert shc_info is not None and shc_info.unique_id is not None
|
||||
|
||||
entities: list[SensorEntity] = [
|
||||
SHCSensor(
|
||||
device,
|
||||
SENSOR_DESCRIPTIONS[sensor_type],
|
||||
session.information.unique_id,
|
||||
description,
|
||||
shc_info.unique_id,
|
||||
config_entry.entry_id,
|
||||
)
|
||||
for device in session.device_helper.thermostats
|
||||
for sensor_type in (TEMPERATURE_SENSOR, VALVE_TAPPET_SENSOR)
|
||||
for description in (
|
||||
_THERMOSTAT_TEMPERATURE_DESCRIPTION,
|
||||
_VALVE_TAPPET_DESCRIPTION,
|
||||
)
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
SHCSensor(
|
||||
device,
|
||||
SENSOR_DESCRIPTIONS[sensor_type],
|
||||
session.information.unique_id,
|
||||
description,
|
||||
shc_info.unique_id,
|
||||
config_entry.entry_id,
|
||||
)
|
||||
for device in session.device_helper.wallthermostats
|
||||
for sensor_type in (TEMPERATURE_SENSOR, HUMIDITY_SENSOR)
|
||||
for description in (
|
||||
_WALLTHERMOSTAT_TEMPERATURE_DESCRIPTION,
|
||||
_WALLTHERMOSTAT_HUMIDITY_DESCRIPTION,
|
||||
)
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
SHCSensor(
|
||||
device,
|
||||
SENSOR_DESCRIPTIONS[sensor_type],
|
||||
session.information.unique_id,
|
||||
description,
|
||||
shc_info.unique_id,
|
||||
config_entry.entry_id,
|
||||
)
|
||||
for device in session.device_helper.twinguards
|
||||
for sensor_type in (
|
||||
TEMPERATURE_SENSOR,
|
||||
HUMIDITY_SENSOR,
|
||||
PURITY_SENSOR,
|
||||
AIR_QUALITY_SENSOR,
|
||||
TEMPERATURE_RATING_SENSOR,
|
||||
HUMIDITY_RATING_SENSOR,
|
||||
PURITY_RATING_SENSOR,
|
||||
for description in (
|
||||
_TWINGUARD_TEMPERATURE_DESCRIPTION,
|
||||
_TWINGUARD_HUMIDITY_DESCRIPTION,
|
||||
_PURITY_DESCRIPTION,
|
||||
_AIR_QUALITY_DESCRIPTION,
|
||||
_TEMPERATURE_RATING_DESCRIPTION,
|
||||
_HUMIDITY_RATING_DESCRIPTION,
|
||||
_PURITY_RATING_DESCRIPTION,
|
||||
)
|
||||
)
|
||||
|
||||
power_meter_devices: list[_PowerMeterDevice] = [
|
||||
*session.device_helper.smart_plugs,
|
||||
*session.device_helper.light_switches_bsm,
|
||||
]
|
||||
entities.extend(
|
||||
SHCSensor(
|
||||
device,
|
||||
SENSOR_DESCRIPTIONS[sensor_type],
|
||||
session.information.unique_id,
|
||||
description,
|
||||
shc_info.unique_id,
|
||||
config_entry.entry_id,
|
||||
)
|
||||
for device in (
|
||||
session.device_helper.smart_plugs + session.device_helper.light_switches_bsm
|
||||
)
|
||||
for sensor_type in (POWER_SENSOR, ENERGY_SENSOR)
|
||||
for device in power_meter_devices
|
||||
for description in (_POWER_DESCRIPTION, _ENERGY_DESCRIPTION)
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
SHCSensor(
|
||||
device,
|
||||
SENSOR_DESCRIPTIONS[sensor_type],
|
||||
session.information.unique_id,
|
||||
description,
|
||||
shc_info.unique_id,
|
||||
config_entry.entry_id,
|
||||
)
|
||||
for device in session.device_helper.smart_plugs_compact
|
||||
for sensor_type in (POWER_SENSOR, ENERGY_SENSOR, COMMUNICATION_QUALITY_SENSOR)
|
||||
for description in (
|
||||
_COMPACT_POWER_DESCRIPTION,
|
||||
_COMPACT_ENERGY_DESCRIPTION,
|
||||
_COMMUNICATION_QUALITY_DESCRIPTION,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class SHCSensor(SHCEntity, SensorEntity):
|
||||
class SHCSensor[_DeviceT: SHCDevice](SHCEntity, SensorEntity):
|
||||
"""Representation of a SHC sensor."""
|
||||
|
||||
entity_description: SHCSensorEntityDescription
|
||||
entity_description: SHCSensorEntityDescription[_DeviceT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: SHCDevice,
|
||||
entity_description: SHCSensorEntityDescription,
|
||||
device: _DeviceT,
|
||||
entity_description: SHCSensorEntityDescription[_DeviceT],
|
||||
parent_id: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(device, parent_id, entry_id)
|
||||
self._device: _DeviceT = device
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{device.serial}_{entity_description.key}"
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Platform for switch integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from boschshcpy import (
|
||||
SHCCamera360,
|
||||
SHCCameraEyes,
|
||||
SHCLightSwitch,
|
||||
CameraLightService,
|
||||
PowerSwitchService,
|
||||
PrivacyModeService,
|
||||
SHCSmartPlug,
|
||||
SHCSmartPlugCompact,
|
||||
)
|
||||
from boschshcpy.device import SHCDevice
|
||||
|
||||
@@ -20,18 +20,19 @@ from homeassistant.components.switch import (
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import BoschConfigEntry
|
||||
from .entity import SHCEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SHCSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Class describing SHC switch entities."""
|
||||
|
||||
on_key: str
|
||||
on_value: StateType
|
||||
on_value: Enum
|
||||
should_poll: bool
|
||||
|
||||
|
||||
@@ -40,35 +41,35 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = {
|
||||
key="smartplug",
|
||||
device_class=SwitchDeviceClass.OUTLET,
|
||||
on_key="switchstate",
|
||||
on_value=SHCSmartPlug.PowerSwitchService.State.ON,
|
||||
on_value=PowerSwitchService.State.ON,
|
||||
should_poll=False,
|
||||
),
|
||||
"smartplugcompact": SHCSwitchEntityDescription(
|
||||
key="smartplugcompact",
|
||||
device_class=SwitchDeviceClass.OUTLET,
|
||||
on_key="switchstate",
|
||||
on_value=SHCSmartPlugCompact.PowerSwitchService.State.ON,
|
||||
on_value=PowerSwitchService.State.ON,
|
||||
should_poll=False,
|
||||
),
|
||||
"lightswitch": SHCSwitchEntityDescription(
|
||||
key="lightswitch",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
on_key="switchstate",
|
||||
on_value=SHCLightSwitch.PowerSwitchService.State.ON,
|
||||
on_value=PowerSwitchService.State.ON,
|
||||
should_poll=False,
|
||||
),
|
||||
"cameraeyes": SHCSwitchEntityDescription(
|
||||
key="cameraeyes",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
on_key="cameralight",
|
||||
on_value=SHCCameraEyes.CameraLightService.State.ON,
|
||||
on_value=CameraLightService.State.ON,
|
||||
should_poll=True,
|
||||
),
|
||||
"camera360": SHCSwitchEntityDescription(
|
||||
key="camera360",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
on_key="privacymode",
|
||||
on_value=SHCCamera360.PrivacyModeService.State.DISABLED,
|
||||
on_value=PrivacyModeService.State.DISABLED,
|
||||
should_poll=True,
|
||||
),
|
||||
}
|
||||
@@ -82,10 +83,14 @@ async def async_setup_entry(
|
||||
"""Set up the SHC switch platform."""
|
||||
session = config_entry.runtime_data
|
||||
|
||||
shc_info = session.information
|
||||
if TYPE_CHECKING:
|
||||
assert shc_info is not None and shc_info.unique_id is not None
|
||||
|
||||
entities: list[SwitchEntity] = [
|
||||
SHCSwitch(
|
||||
device=switch,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
description=SWITCH_TYPES["smartplug"],
|
||||
)
|
||||
@@ -95,7 +100,7 @@ async def async_setup_entry(
|
||||
entities.extend(
|
||||
SHCRoutingSwitch(
|
||||
device=switch,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
)
|
||||
for switch in session.device_helper.smart_plugs
|
||||
@@ -104,7 +109,7 @@ async def async_setup_entry(
|
||||
entities.extend(
|
||||
SHCSwitch(
|
||||
device=switch,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
description=SWITCH_TYPES["lightswitch"],
|
||||
)
|
||||
@@ -114,7 +119,7 @@ async def async_setup_entry(
|
||||
entities.extend(
|
||||
SHCSwitch(
|
||||
device=switch,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
description=SWITCH_TYPES["smartplugcompact"],
|
||||
)
|
||||
@@ -124,7 +129,7 @@ async def async_setup_entry(
|
||||
entities.extend(
|
||||
SHCSwitch(
|
||||
device=switch,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
description=SWITCH_TYPES["cameraeyes"],
|
||||
)
|
||||
@@ -134,7 +139,7 @@ async def async_setup_entry(
|
||||
entities.extend(
|
||||
SHCSwitch(
|
||||
device=switch,
|
||||
parent_id=session.information.unique_id,
|
||||
parent_id=shc_info.unique_id,
|
||||
entry_id=config_entry.entry_id,
|
||||
description=SWITCH_TYPES["camera360"],
|
||||
)
|
||||
@@ -166,7 +171,7 @@ class SHCSwitch(SHCEntity, SwitchEntity):
|
||||
"""Return the state of the switch."""
|
||||
return (
|
||||
getattr(self._device, self.entity_description.on_key)
|
||||
== self.entity_description.on_value
|
||||
is self.entity_description.on_value
|
||||
)
|
||||
|
||||
@override
|
||||
@@ -195,9 +200,10 @@ class SHCRoutingSwitch(SHCEntity, SwitchEntity):
|
||||
|
||||
_attr_translation_key = "routing"
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_device: SHCSmartPlug
|
||||
|
||||
def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
|
||||
"""Initialize an SHC communication quality reporting sensor."""
|
||||
"""Initialize an SHC routing switch."""
|
||||
super().__init__(device, parent_id, entry_id)
|
||||
self._attr_unique_id = f"{device.serial}_routing"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pybravia"],
|
||||
"requirements": ["pybravia==0.4.1"],
|
||||
"requirements": ["pybravia==0.5.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Sony Corporation",
|
||||
|
||||
@@ -81,8 +81,8 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
|
||||
"""
|
||||
device_registry = dr.async_get(hass)
|
||||
assert entry.unique_id
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.unique_id)}
|
||||
device_entry = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, entry.unique_id), entry.entry_id
|
||||
)
|
||||
assert device_entry
|
||||
device_registry.async_update_device(device_entry.id, name=entry.title)
|
||||
|
||||
@@ -35,7 +35,7 @@ async def async_setup_entry(
|
||||
# Add a device for the SAM itself.
|
||||
sam_uid = names.sam_device_uid(entry)
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
sam_device = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, sam_uid)},
|
||||
manufacturer="Bryant",
|
||||
@@ -55,7 +55,7 @@ async def async_setup_entry(
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, names.system_device_uid(sam_uid, sys_id))},
|
||||
via_device=(DOMAIN, names.sam_device_uid(entry)),
|
||||
via_device_id=sam_device.id,
|
||||
manufacturer="Bryant",
|
||||
name=f"System {sys_id}",
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -45,6 +46,7 @@ async def async_setup_entry(
|
||||
zone_id = sz[1]
|
||||
client = config_entry.runtime_data.get(tuple(sz))
|
||||
climate = BryantEvolutionClimate(
|
||||
hass,
|
||||
client,
|
||||
system_id,
|
||||
zone_id,
|
||||
@@ -82,6 +84,7 @@ class BryantEvolutionClimate(ClimateEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: BryantEvolutionLocalClient,
|
||||
system_id: int,
|
||||
zone_id: int,
|
||||
@@ -94,7 +97,11 @@ class BryantEvolutionClimate(ClimateEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
manufacturer="Bryant",
|
||||
via_device=(DOMAIN, names.system_device_uid(sam_uid, system_id)),
|
||||
via_device_id=dr.async_get_device_id_by_identifier(
|
||||
hass,
|
||||
(DOMAIN, names.system_device_uid(sam_uid, system_id)),
|
||||
config_entry_id=sam_uid, # This is the config entry id
|
||||
),
|
||||
name=f"System {system_id} Zone {zone_id}",
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import override
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -53,7 +54,11 @@ class BSBLanCircuitEntity(BSBLanEntity):
|
||||
identifiers={(DOMAIN, f"{mac}-circuit-{circuit}")},
|
||||
translation_key="heating_circuit",
|
||||
translation_placeholders={"circuit": str(circuit)},
|
||||
via_device=(DOMAIN, mac),
|
||||
via_device_id=dr.async_get_device_id_by_identifier(
|
||||
coordinator.hass,
|
||||
(DOMAIN, mac),
|
||||
config_entry_id=coordinator.config_entry.entry_id,
|
||||
),
|
||||
manufacturer=main_info["manufacturer"],
|
||||
model=main_info.get("model"),
|
||||
model_id=main_info.get("model_id"),
|
||||
@@ -101,7 +106,11 @@ class BSBLanWaterHeaterDeviceEntity(BSBLanDualCoordinatorEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{mac}-water-heater")},
|
||||
translation_key="water_heater",
|
||||
via_device=(DOMAIN, mac),
|
||||
via_device_id=dr.async_get_device_id_by_identifier(
|
||||
fast_coordinator.hass,
|
||||
(DOMAIN, mac),
|
||||
config_entry_id=fast_coordinator.config_entry.entry_id,
|
||||
),
|
||||
manufacturer=main_info["manufacturer"],
|
||||
model=main_info.get("model"),
|
||||
model_id=main_info.get("model_id"),
|
||||
|
||||
@@ -231,7 +231,7 @@ async def _async_get_stream_image(
|
||||
height: int | None = None,
|
||||
wait_for_next_keyframe: bool = False,
|
||||
) -> bytes | None:
|
||||
if (provider := camera._webrtc_provider) and ( # noqa: SLF001
|
||||
if (provider := camera.webrtc_provider) and (
|
||||
image := await provider.async_get_image(camera, width=width, height=height)
|
||||
) is not None:
|
||||
return image
|
||||
@@ -407,6 +407,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
async def _async_call_webrtc_provider(
|
||||
coro: Coroutine[Any, Any, None], description: str, entity_id: str
|
||||
) -> None:
|
||||
"""Await a WebRTC provider callback without letting exceptions propagate.
|
||||
|
||||
Provider callbacks can do I/O and must not break camera setup or removal.
|
||||
"""
|
||||
try:
|
||||
await coro
|
||||
except HomeAssistantError as ex:
|
||||
_LOGGER.error("Error %s %s: %s", description, entity_id, ex)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error %s %s", description, entity_id)
|
||||
|
||||
|
||||
CACHED_PROPERTIES_WITH_ATTR_ = {
|
||||
"brand",
|
||||
"frame_interval",
|
||||
@@ -514,6 +529,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@final
|
||||
@property
|
||||
def webrtc_provider(self) -> CameraWebRTCProvider | None:
|
||||
"""Return the WebRTC provider."""
|
||||
return self._webrtc_provider
|
||||
|
||||
async def async_create_stream(self) -> Stream | None:
|
||||
"""Create a Stream for stream_source."""
|
||||
# There is at most one stream (a decode worker) per camera
|
||||
@@ -683,6 +704,18 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM
|
||||
await self.async_refresh_providers(write_state=False)
|
||||
|
||||
@override
|
||||
async def async_internal_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
if self._webrtc_provider:
|
||||
await _async_call_webrtc_provider(
|
||||
self._webrtc_provider.async_unregister_camera(self),
|
||||
"unregistering WebRTC provider for",
|
||||
self.entity_id,
|
||||
)
|
||||
self._webrtc_provider = None
|
||||
await super().async_internal_will_remove_from_hass()
|
||||
|
||||
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
||||
"""Determine if any of the registered providers are suitable for this entity.
|
||||
|
||||
@@ -699,11 +732,27 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
async_get_supported_provider
|
||||
)
|
||||
|
||||
if old_provider != new_provider:
|
||||
self._webrtc_provider = new_provider
|
||||
self._invalidate_camera_capabilities_cache()
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
if old_provider == new_provider:
|
||||
return
|
||||
|
||||
if old_provider:
|
||||
await _async_call_webrtc_provider(
|
||||
old_provider.async_unregister_camera(self),
|
||||
"unregistering WebRTC provider for",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
if new_provider:
|
||||
await _async_call_webrtc_provider(
|
||||
new_provider.async_register_camera(self),
|
||||
"registering WebRTC provider for",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
self._webrtc_provider = new_provider
|
||||
self._invalidate_camera_capabilities_cache()
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_get_supported_webrtc_provider[_T](
|
||||
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
|
||||
@@ -969,6 +1018,14 @@ async def websocket_update_prefs(
|
||||
_LOGGER.error("Error setting camera preferences: %s", ex)
|
||||
connection.send_error(msg["id"], "update_failed", str(ex))
|
||||
else:
|
||||
if (camera := hass.data[DATA_COMPONENT].get_entity(entity_id)) and (
|
||||
provider := camera.webrtc_provider
|
||||
):
|
||||
await _async_call_webrtc_provider(
|
||||
provider.async_on_camera_prefs_update(camera),
|
||||
"notifying WebRTC provider of preferences update for",
|
||||
entity_id,
|
||||
)
|
||||
connection.send_result(msg["id"], entity_prefs)
|
||||
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ class CameraWebRTCProvider(ABC):
|
||||
@callback
|
||||
def async_close_session(self, session_id: str) -> None:
|
||||
"""Close the session."""
|
||||
return ## This is an optional method so we need a default here.
|
||||
return # This is an optional method so we need a default here.
|
||||
|
||||
async def async_get_image(
|
||||
self,
|
||||
@@ -156,6 +156,18 @@ class CameraWebRTCProvider(ABC):
|
||||
"""Get an image from the camera."""
|
||||
return None
|
||||
|
||||
async def async_register_camera(self, camera: Camera) -> None:
|
||||
"""Will be called when the provider is registered for a camera."""
|
||||
return # This is an optional method so we need a default here.
|
||||
|
||||
async def async_unregister_camera(self, camera: Camera) -> None:
|
||||
"""Will be called when the provider is unregistered for a camera."""
|
||||
return # This is an optional method so we need a default here.
|
||||
|
||||
async def async_on_camera_prefs_update(self, camera: Camera) -> None:
|
||||
"""Will be called when the camera preferences are updated."""
|
||||
return # This is an optional method so we need a default here.
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_webrtc_provider(
|
||||
|
||||
@@ -20,6 +20,16 @@ from .const import DOMAIN, LOCAL_NAMES
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_casper_glow_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
|
||||
"""Return whether the Bluetooth discovery looks like a Casper Glow."""
|
||||
return bool(
|
||||
discovery_info.name
|
||||
and any(
|
||||
discovery_info.name.startswith(local_name) for local_name in LOCAL_NAMES
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CasperGlowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Casper Glow."""
|
||||
|
||||
@@ -36,6 +46,9 @@ class CasperGlowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
if not _is_casper_glow_discovery(discovery_info):
|
||||
return self.async_abort(reason="not_supported")
|
||||
|
||||
await self.async_set_unique_id(format_mac(discovery_info.address))
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = discovery_info
|
||||
@@ -118,13 +131,7 @@ class CasperGlowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if (
|
||||
format_mac(discovery.address) in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not (
|
||||
discovery.name
|
||||
and any(
|
||||
discovery.name.startswith(local_name)
|
||||
for local_name in LOCAL_NAMES
|
||||
)
|
||||
)
|
||||
or not _is_casper_glow_discovery(discovery)
|
||||
):
|
||||
continue
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user