Compare commits

..

7 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer dbc5cfbd61 migrate name
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-09 19:45:53 +02:00
Daniel Hjelseth Høyer d8fcbd56e4 migrate name
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 13:32:39 +02:00
Daniel Hjelseth Høyer 0feff00e1a title
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 12:16:48 +02:00
Daniel Hjelseth Høyer dfaa57d7cb Merge branch 'dev' into 160769 2026-05-07 11:56:14 +02:00
Daniel Hjelseth Høyer 063a843f4c migrate name
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 08:43:22 +02:00
Daniel Hjelseth Høyer 2dce45888c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-06 20:36:01 +02:00
Daniel Hjelseth Høyer 9b514a4cb1 Remove name field from met config flow
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-06 19:30:11 +02:00
4169 changed files with 22752 additions and 106630 deletions
+1 -1
View File
@@ -14,6 +14,7 @@ Dockerfile.dev linguist-language=Dockerfile
# Generated files
CODEOWNERS linguist-generated=true
Dockerfile linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true
@@ -22,4 +23,3 @@ requirements_all.txt linguist-generated=true
requirements_test_all.txt linguist-generated=true
requirements_test_pre_commit.txt linguist-generated=true
script/hassfest/docker/Dockerfile linguist-generated=true
.github/workflows/*.lock.yml linguist-generated=true
+2 -12
View File
@@ -16,15 +16,9 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
## Pull Requests
- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template.
- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected.
## Development Commands
- 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.
- .vscode/tasks.json contains useful commands used for development.
.vscode/tasks.json contains useful commands used for development.
## Python Syntax Notes
@@ -34,14 +28,10 @@ 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`.
- 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.
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
## Good practices
-3
View File
@@ -11,6 +11,3 @@ updates:
- github_actions
cooldown:
default-days: 7
ignore:
# Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
- dependency-name: "github/gh-aw-actions/**"
+1 -28
View File
@@ -6,7 +6,6 @@
"pep621",
"pip_requirements",
"pre-commit",
"dockerfile",
"custom.regex",
"homeassistant-manifest"
],
@@ -22,10 +21,6 @@
]
},
"dockerfile": {
"managerFilePatterns": ["/^Dockerfile$/"]
},
"homeassistant-manifest": {
"managerFilePatterns": [
"/^homeassistant/components/[^/]+/manifest\\.json$/"
@@ -40,14 +35,6 @@
"matchStrings": ["required-version = \">=(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ruff",
"datasourceTemplate": "pypi"
},
{
"customType": "regex",
"description": "Update go2rtc RECOMMENDED_VERSION in const.py alongside the Dockerfile pin",
"managerFilePatterns": ["/^homeassistant/components/go2rtc/const\\.py$/"],
"matchStrings": ["RECOMMENDED_VERSION = \"(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ghcr.io/alexxit/go2rtc",
"datasourceTemplate": "docker"
}
],
@@ -141,8 +128,7 @@
"home-assistant-bluetooth",
"home-assistant-frontend",
"home-assistant-intents",
"infrared-protocols",
"rf-protocols"
"infrared-protocols"
],
"enabled": true,
"minimumReleaseAge": null,
@@ -197,13 +183,6 @@
"enabled": true,
"labels": ["dependency"]
},
{
"description": "Docker allowlist (ghcr.io exposes no release timestamps so the global cooldown needs to be bypassed)",
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
"enabled": true,
"minimumReleaseAge": null,
"labels": ["dependency"]
},
{
"description": "Group ruff pre-commit hook with its PyPI twin into one PR",
"matchPackageNames": ["astral-sh/ruff-pre-commit", "ruff"],
@@ -233,12 +212,6 @@
"matchPackageNames": ["pylint", "astroid"],
"groupName": "pylint",
"groupSlug": "pylint"
},
{
"description": "Group go2rtc Dockerfile pin with const.py RECOMMENDED_VERSION into one PR",
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
"groupName": "go2rtc",
"groupSlug": "go2rtc"
}
]
}
+1 -1
View File
@@ -338,7 +338,7 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: "v2.5.3"
File diff suppressed because it is too large Load Diff
-405
View File
@@ -1,405 +0,0 @@
---
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "requirements*.txt"
- "homeassistant/package_constraints.txt"
- "pyproject.toml"
forks: ["*"]
workflow_dispatch:
inputs:
pull_request_number:
description: "Pull request number to (re-)check"
required: true
type: number
roles: all
permissions:
contents: read
pull-requests: read
issues: read
network:
allowed:
- python
tools:
web-fetch: {}
github:
toolsets: [default]
safe-outputs:
add-comment:
max: 1
description: >
Checks changed Python package requirements on PRs targeting the core repo
(including PRs opened from forks) and verifies licenses match PyPI metadata, source
repositories are publicly accessible, PyPI releases were uploaded via
automated CI (Trusted Publisher attestation), the package's release pipeline
uses OIDC or equivalent automated credentials (not static tokens), and the PR
description contains the required links.
---
# Requirements License and Availability Check
You are a code review assistant for the Home Assistant project. Your job is to
review changes to Python package requirements and verify they meet the project's
standards.
## Context
- Home Assistant uses `requirements_all.txt` (all integration packages),
`requirements.txt` (core packages), `requirements_test.txt` (test
dependencies), and `requirements_test_all.txt` (all test dependencies) to
declare Python dependencies.
- Each integration lists its packages in `homeassistant/components/<name>/manifest.json`
under the `requirements` field.
- Allowed licenses are maintained in `script/licenses.py` under
`OSI_APPROVED_LICENSES_SPDX` (SPDX identifiers) and `OSI_APPROVED_LICENSES`
(classifier strings).
## Step 1 — Identify Changed Packages
Use the GitHub tool to fetch the PR diff. Look for lines that were added (`+`)
or removed (`-`) in **any** of these files:
- `requirements.txt`
- `requirements_all.txt`
- `requirements_test.txt`
- `requirements_test_all.txt`
- `homeassistant/package_constraints.txt`
- `pyproject.toml`
For each changed line that contains a package pin (e.g. `SomePackage==1.2.3`),
classify it as:
- **New package**: the package name appears only in `+` lines, with no
corresponding `-` line for the same package name.
- **Version bump**: the same package name appears in both `+` lines (new
version) and `-` lines (old version), with different version numbers.
Record the **old version** and **new version** for every version bump — you
will need these values in Step 4.
## Step 2 — Check License via PyPI
For each new or bumped package:
1. Fetch `https://pypi.org/pypi/{package_name}/json` (use the exact
package name as it appears on the requirements file).
2. From the JSON response, extract:
- `info.license` — free-text license field
- `info.license_expression` — SPDX expression (if present)
- `info.classifiers` — filter for entries starting with `"License ::"`,
then normalize each match the same way as `script/licenses.py` by
extracting the final ` :: ` segment (for example,
`"License :: OSI Approved :: MIT License"``"MIT License"`).
3. Determine if the license is in the approved list from `script/licenses.py`:
- SPDX identifiers: compare against `OSI_APPROVED_LICENSES_SPDX`
- Normalized classifier strings: compare against `OSI_APPROVED_LICENSES`
4. Flag a package as ❌ if the license is unknown, missing, or not in the
approved list. Flag as ⚠️ if the license information is ambiguous or cannot
be definitively determined.
## Step 2b — Verify PyPI Release Was Uploaded by CI
For each new or bumped package, verify that the release on PyPI was published
automatically by a CI pipeline (via OIDC Trusted Publisher), not uploaded
manually.
1. Fetch the PyPI JSON for the specific version being introduced or bumped:
`https://pypi.org/pypi/{package_name}/{version}/json`
2. Inspect the `urls` array in the response. For each distribution file (wheel
or sdist), note the filename.
3. For each filename, attempt to fetch the PyPI provenance attestation:
`https://pypi.org/integrity/{package_name}/{version}/{filename}/provenance`
- If the response is HTTP 200 and contains a valid attestation object,
inspect `attestation_bundles[*].publisher`. A Trusted Publisher attestation
will have a `kind` identifying the CI system (e.g. `"GitHub Actions"`,
`"GitLab"`) and a `repository` or `project` field matching the source
repository.
- If at least one distribution file has a valid Trusted Publisher attestation,
mark ✅ CI-uploaded.
- If no attestation is found for any file (404 for all), mark ❌ — "Release
has no provenance attestation; it may have been uploaded manually".
- If an attestation exists but the `publisher` does not identify a recognized
CI system or Trusted Publisher, mark ⚠️ — "Attestation present but
publisher cannot be verified as automated CI".
Note: if PyPI returns an error fetching the per-version JSON, fall back to the
latest JSON (`https://pypi.org/pypi/{package_name}/json`) and look up the
specific version in the `releases` dict.
## Step 3 — Identify Repository URL
For each new or bumped package:
1. From the PyPI JSON at `info.project_urls`, find the source repository URL
(keys such as `"Source"`, `"Homepage"`, `"Repository"`, or `"Source Code"`).
2. Record that repository URL for later checks.
3. If no suitable repository URL is present, mark ❌ with a note that the
source repository URL is missing and cannot be verified.
## Step 4 — Check PR Description
Read the PR body from the GitHub API using the PR number from the workflow
context (`pull-request-number`). If that value is absent, use the
`workflow_dispatch` input `pull_request_number`.
Extract all URLs present in the PR body.
### 4a — New packages: repository link required
For **new packages** (brand-new dependency not previously in any requirements
file): the PR description must contain a link that points to the package's
**source repository** as identified in Step 3 (the URL recorded from
`info.project_urls`). A PyPI page link alone is **not** acceptable — the link
must point directly to the source repository (e.g. a GitHub or GitLab URL).
- If a URL in the PR body matches (or is a sub-path of) the source repository
URL identified via PyPI, mark ✅.
- If the PR body contains a source repository URL that does **not** match the
repository URL found in the package's PyPI metadata (`info.project_urls`),
mark ❌ — "PR description links to `<pr_url>` but PyPI reports the source
repository as `<pypi_repo_url>`; please use the correct repository URL."
- If no source repository URL is present in the PR body at all, mark ❌ —
"PR description must link to the source repository at `<repo_url>` (found
via PyPI). A PyPI page link is not sufficient."
### 4b — Version bumps: changelog or diff link required
For **version bumps**: the PR description must contain a link to a changelog,
release notes page, or a diff/comparison URL that references the **correct
versions** being bumped (old → new).
Checks to perform for each bumped package (old version = X, new version = Y):
1. Extract all URLs from the PR body that contain the repository's domain or
path (as identified in Step 3).
2. Verify that at least one such URL includes both the old version string and
new version string in some form — e.g. a GitHub compare URL like
`compare/vX...vY`, a releases URL mentioning version Y, or a
`CHANGELOG.md` anchor referencing Y.
3. If no URL matches, check if the PR body contains any changelog/diff link at
all for this package.
Outcome:
- ✅ — a URL pointing to the correct repo with version references covering the
exact bump (X → Y).
- ⚠️ — a changelog/diff link exists but does not clearly reference the correct
versions or the correct repository; explain what was found and what is
expected.
- ❌ — no changelog or diff link found at all in the PR description for this
package.
### 4c — Diff consistency check
For each **version bump**, verify that the version change recorded in the diff
(Step 1) is internally consistent:
- The `-` line must contain the old version and the `+` line must contain the
new version for the same package name.
- Flag ❌ if the diff shows a downgrade (new version < old version) without an
explanation, or if the version strings cannot be parsed.
## Step 5 — Verify Source Repository is Publicly Accessible
Before inspecting the release pipeline, confirm that the source repository
identified in Step 3 is publicly reachable.
For each new or bumped package:
1. Use the source repository URL recorded in Step 3.
2. If no repository URL was found in `info.project_urls`, mark ❌ — "No source
repository URL found in PyPI metadata; a public source repository is
required."
3. If a repository URL was found, perform a GET request to that URL (using
web-fetch). If the response is HTTP 200 and returns a publicly accessible
page (not a login redirect or error page), mark ✅.
4. If the response is non-200, the URL redirects to a login/authentication page,
or the repository appears private or unavailable, mark ❌ — "Source
repository at `<repo_url>` is not publicly accessible. Home Assistant
requires all dependencies to have publicly available source code." **Do not
proceed with the release pipeline check (Step 6) for this package.**
## Step 6 — Check Release Pipeline Sanity
For each new or bumped package, determine the source repository host from the
URL identified in Step 3, then inspect whether the project's release/publish CI
workflow is sane. The checks differ by hosting provider.
### GitHub repositories (`github.com`)
1. Using the GitHub API, list the workflows in the source repository:
`GET /repos/{owner}/{repo}/actions/workflows`
2. Identify any workflow whose name or filename suggests publishing to PyPI
(e.g., contains "release", "publish", "pypi", or "deploy").
3. Fetch the workflow file content and check the following:
a. **Trigger sanity**: The publish job should be triggered by `push` to tags,
`release: published`, or `workflow_run` on a release job — **not** solely
by `workflow_dispatch` with no additional guards. A `workflow_dispatch`
trigger alongside other triggers is acceptable. Mark ❌ if the only trigger
is manual `workflow_dispatch` with no environment protection rules.
b. **OIDC / Trusted Publisher**: The workflow should use OIDC-based publishing.
Look for `id-token: write` permission and one of:
- `pypa/gh-action-pypi-publish` action
- `actions/attest-build-provenance` action
- Any step that sets `TWINE_PASSWORD` from `secrets.PYPI_TOKEN` directly
(flag ❌ if a long-lived API token is used instead of OIDC).
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined,
❌ if a static secret token is the only credential.
c. **No manual upload bypass**: Verify there is no step that calls
`twine upload` or `pip upload` outside of a properly gated job (e.g., one
that requires an environment approval). Flag ⚠️ if such steps exist.
4. If no publish workflow is found in the repository, mark ⚠️ — "No publish
workflow found; it is unclear how this package is released to PyPI."
### GitLab repositories (`gitlab.com` or self-hosted GitLab)
1. Use the GitLab REST API to list CI/CD pipeline configuration files. First
resolve the project ID via
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`
and note the `id` field.
2. Fetch the repository's `.gitlab-ci.yml` (and any included files) using
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`
(use web-fetch for public repos).
3. Identify any job whose name or `stage` suggests publishing to PyPI
(e.g., "publish", "deploy", "release", "pypi").
4. For each such job, check:
a. **Trigger sanity**: The job should run only on tag pipelines (`only: tags`
or `rules: - if: $CI_COMMIT_TAG`) or on protected branches — **not**
solely on manual triggers (`when: manual`) with no additional protection.
Mark ❌ if the only trigger is manual with no environment or protected-branch
guard.
b. **Automated credentials**: The job should use GitLab's OIDC ID token
(`id_tokens:` block) and `pypa/gh-action-pypi-publish` equivalent, or
reference `secrets.PYPI_TOKEN` / `$PYPI_TOKEN` injected from GitLab CI/CD
protected variables (flag ❌ if the token is hard-coded or unprotected).
Mark ✅ if OIDC or protected CI variables are used, ⚠️ if the method
cannot be determined, ❌ if credentials appear to be insecure.
c. **No manual upload bypass**: Flag ⚠️ if any job calls `twine upload`
without being behind a protected-variable or environment guard.
5. If no publish job is found, mark ⚠️ — "No publish job found in .gitlab-ci.yml;
it is unclear how this package is released to PyPI."
### Other code hosting providers
For repositories hosted on platforms other than GitHub or GitLab (e.g.,
Bitbucket, Codeberg, Gitea, Sourcehut):
1. Use web-fetch to retrieve the repository's root page and look for any
publicly visible CI configuration files (e.g., `.circleci/config.yml`,
`Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`,
`.builds/*.yml` for Sourcehut).
2. Apply the same conceptual checks as above:
- Does publishing run on automated triggers (tags/releases), not solely
manual ones?
- Are credentials injected by the CI system (not hard-coded)?
- Is there a `twine upload` or equivalent step that could be run manually?
3. If no CI configuration can be retrieved, mark ⚠️ — "Release pipeline could
not be inspected; hosting provider is not GitHub or GitLab."
## Step 7 — Post a Review Comment
**Always** post a review comment using `add_comment`, regardless of whether
packages pass or fail. Use the following structure:
**Note on deduplication**: The workflow automatically updates any previous
requirements-check comment on the PR in place (preserving its position in the
thread). If no previous comment exists, the newly created comment is kept as-is.
You do not need to search for or update previous comments yourself.
### Comment structure
Begin every comment with the HTML marker `<!-- requirements-check -->` on its
own line (this is used by the workflow to find the previous comment and update
it on the next run).
### 7a — Overall summary line
Begin the comment with a single summary line, before anything else:
- If everything passed: `All requirements checks passed. ✅`
- If there are failures or warnings: `⚠️ Some checks require attention — see the details below.`
### 7b — Summary table
Render a compact table where every check column contains **only the status
icon** (✅, ⚠️, or ❌). No explanatory text belongs inside the table cells —
all detail goes in the per-package sections below.
Use `—` (em dash) when a check was skipped (e.g. Release Pipeline is skipped
when the repository is not publicly accessible).
```
<!-- requirements-check -->
## Requirements Check
| Package | Type | Old→New | License | Repo Public | CI Upload | Release Pipeline | PR Link | Diff Consistent |
|---------|------|---------|---------|-------------|-----------|------------------|---------|-----------------|
| PackageA | bump | 1.2.3→1.3.0 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| PackageB | new | —→4.5.6 | ❌ | ✅ | ❌ | ⚠️ | ❌ | ✅ |
| PackageC | bump | 2.0.0→2.1.0 | ✅ | ❌ | — | — | ⚠️ | ✅ |
```
### 7c — Per-package detail sections
After the table, add one collapsible `<details>` block per package.
- If **all checks passed** for that package, render the block **collapsed**
(no `open` attribute) so the comment stays concise.
- If **any check failed or produced a warning**, render the block **open**
(`<details open>`) so the contributor sees the issues immediately.
Each block must include the full detail for every check: the license found, the
repository URL, whether a provenance attestation was found, the release
pipeline findings, the PR link found (or missing), and whether the diff is
consistent. For failed or warned checks, explain exactly what the contributor
must fix, including the expected source repository URL, expected version range,
etc.
Template (repeat for each package):
```
<details open>
<summary><strong>PackageB 📦 new —→4.5.6</strong></summary>
- **License**: ❌ License is `UNKNOWN` — not in the approved list. Check PyPI metadata and `script/licenses.py`.
- **Repository Public**: ✅ https://github.com/example/packageb is publicly accessible.
- **CI Upload**: ❌ No provenance attestation found for any distribution file. The release may have been uploaded manually.
- **Release Pipeline**: ⚠️ No publish workflow found in the repository; it is unclear how this package is released to PyPI.
- **PR Link**: ❌ PR description must link to the source repository at https://github.com/example/packageb (a PyPI page link is not sufficient).
- **Diff Consistent**: ✅
</details>
```
Collapsed example (all checks passed):
```
<details>
<summary><strong>PackageA 📦 bump 1.2.3→1.3.0</strong></summary>
- **License**: ✅ MIT
- **Repository Public**: ✅ https://github.com/example/packagea
- **CI Upload**: ✅ Trusted Publisher attestation found (GitHub Actions).
- **Release Pipeline**: ✅ OIDC via `pypa/gh-action-pypi-publish`; triggered on `release: published`; `environment: release` gate.
- **PR Link**: ✅ https://github.com/example/packagea/compare/v1.2.3...v1.3.0
- **Diff Consistent**: ✅
</details>
```
## Notes
- Be constructive and helpful. Provide direct links where possible so the
contributor can quickly fix the issue.
- If PyPI returns an error for a package, mention that it could not be found and
suggest the contributor verify the package name.
- For packages that only appear in `homeassistant/package_constraints.txt` or
`pyproject.toml` without being tied to a specific integration, the PR
description link requirement still applies.
- When checking test-only packages (from `requirements_test.txt` or
`requirements_test_all.txt`), apply the same license, repository, and PR
description checks as for production dependencies.
- A package that appears in both a production file and a test file should only
be reported once; use the production file entry as the canonical one.
- This workflow is only triggered when a commit actually changes one of the
tracked requirements files (for `synchronize` events GitHub compares the
before/after SHAs of the push, not the entire PR diff). Members can manually
retrigger the workflow via `workflow_dispatch` with the PR number to re-run
the check after updating the PR description or fixing issues without changing
any requirements files. On a retrigger the existing comment is updated in
place so there is always exactly one requirements-check comment in the PR.
+4 -8
View File
@@ -632,7 +632,7 @@ jobs:
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
license-check: false # We use our own license audit checks
@@ -853,7 +853,7 @@ jobs:
run: |
. venv/bin/activate
python --version
mypy --num-workers=4 homeassistant pylint
mypy homeassistant pylint
- name: Run mypy (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
@@ -862,7 +862,7 @@ jobs:
run: |
. venv/bin/activate
python --version
mypy --num-workers=4 $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
prepare-pytest-full:
name: Split tests for full run
@@ -1088,7 +1088,6 @@ jobs:
options: >-
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
--health-interval=5s --health-timeout=2s --health-retries=3
--tmpfs /var/lib/mysql:size=2g,mode=0750
needs:
- info
- base
@@ -1246,10 +1245,7 @@ jobs:
- 5432:5432
env:
POSTGRES_PASSWORD: password
options: >-
--health-cmd="pg_isready -hlocalhost -Upostgres"
--health-interval=5s --health-timeout=2s --health-retries=3
--tmpfs /var/lib/postgresql/data:size=2g,mode=0700
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
needs:
- info
- base
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
category: "/language:python"
+1 -3
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.13
rev: v0.15.12
hooks:
- id: ruff-check
args:
@@ -23,7 +23,6 @@ repos:
- id: zizmor
args:
- --pedantic
exclude: ^\.github/workflows/.*\.lock\.yml$
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
@@ -47,7 +46,6 @@ repos:
additional_dependencies:
- prettier@3.6.2
- prettier-plugin-sort-json@4.2.0
exclude: ^\.github/workflows/.*\.lock\.yml$
- repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0
hooks:
-8
View File
@@ -139,7 +139,6 @@ homeassistant.components.cambridge_audio.*
homeassistant.components.camera.*
homeassistant.components.canary.*
homeassistant.components.casper_glow.*
homeassistant.components.centriconnect.*
homeassistant.components.cert_expiry.*
homeassistant.components.clickatell.*
homeassistant.components.clicksend.*
@@ -156,7 +155,6 @@ homeassistant.components.counter.*
homeassistant.components.cover.*
homeassistant.components.cpuspeed.*
homeassistant.components.crownstone.*
homeassistant.components.data_grand_lyon.*
homeassistant.components.date.*
homeassistant.components.datetime.*
homeassistant.components.deako.*
@@ -250,7 +248,6 @@ homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*
homeassistant.components.group.*
homeassistant.components.guardian.*
homeassistant.components.guntamatic.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
@@ -298,7 +295,6 @@ homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.indevolt.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
@@ -358,7 +354,6 @@ homeassistant.components.lunatone.*
homeassistant.components.lutron.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.marantz_infrared.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
@@ -425,11 +420,9 @@ homeassistant.components.opower.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
homeassistant.components.otp.*
homeassistant.components.ouman_eh_800.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.*
homeassistant.components.paperless_ngx.*
homeassistant.components.peblar.*
@@ -492,7 +485,6 @@ homeassistant.components.rss_feed_template.*
homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsung_infrared.*
homeassistant.components.samsungtv.*
homeassistant.components.saunum.*
homeassistant.components.scene.*
-1
View File
@@ -1,6 +1,5 @@
ignore: |
tests/fixtures/core/config/yaml_errors/
.github/workflows/*.lock.yml
rules:
braces:
level: error
+2 -12
View File
@@ -6,15 +6,9 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
## Pull Requests
- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template.
- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected.
## Development Commands
- 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.
- .vscode/tasks.json contains useful commands used for development.
.vscode/tasks.json contains useful commands used for development.
## Python Syntax Notes
@@ -24,14 +18,10 @@ 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`.
- 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.
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
## Good practices
Generated
+4 -27
View File
@@ -68,8 +68,6 @@ CLAUDE.md @home-assistant/core
/tests/components/agent_dvr/ @ispysoftware
/homeassistant/components/ai_task/ @home-assistant/core
/tests/components/ai_task/ @home-assistant/core
/homeassistant/components/aidot/ @s1eedz @HongBryan
/tests/components/aidot/ @s1eedz @HongBryan
/homeassistant/components/air_quality/ @home-assistant/core
/tests/components/air_quality/ @home-assistant/core
/homeassistant/components/airgradient/ @airgradienthq @joostlek
@@ -198,7 +196,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/autoskope/ @mcisk
/tests/components/autoskope/ @mcisk
/homeassistant/components/avea/ @pattyland
/tests/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @ricohageman
/tests/components/awair/ @ahayworth @ricohageman
/homeassistant/components/aws_s3/ @tomasbedrich
@@ -291,16 +288,12 @@ CLAUDE.md @home-assistant/core
/tests/components/cast/ @emontnemery
/homeassistant/components/ccm15/ @ocalvo
/tests/components/ccm15/ @ocalvo
/homeassistant/components/centriconnect/ @gresrun
/tests/components/centriconnect/ @gresrun
/homeassistant/components/cert_expiry/ @jjlawren
/tests/components/cert_expiry/ @jjlawren
/homeassistant/components/chacon_dio/ @cnico
/tests/components/chacon_dio/ @cnico
/homeassistant/components/chess_com/ @joostlek
/tests/components/chess_com/ @joostlek
/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo
/tests/components/cielo_home/ @ihsan-cielo @mudasar-cielo
/homeassistant/components/cisco_ios/ @fbradyirl
/homeassistant/components/cisco_mobility_express/ @fbradyirl
/homeassistant/components/cisco_webex_teams/ @fbradyirl
@@ -352,8 +345,6 @@ CLAUDE.md @home-assistant/core
/tests/components/cync/ @Kinachi249
/homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike
/homeassistant/components/data_grand_lyon/ @Crocmagnon
/tests/components/data_grand_lyon/ @Crocmagnon
/homeassistant/components/date/ @home-assistant/core
/tests/components/date/ @home-assistant/core
/homeassistant/components/datetime/ @home-assistant/core
@@ -697,8 +688,6 @@ CLAUDE.md @home-assistant/core
/tests/components/growatt_server/ @johanzander
/homeassistant/components/guardian/ @bachya
/tests/components/guardian/ @bachya
/homeassistant/components/guntamatic/ @JensTimmerman
/tests/components/guntamatic/ @JensTimmerman
/homeassistant/components/habitica/ @tr4nt0r
/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hanna/ @bestycame
@@ -983,8 +972,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom
/tests/components/letpot/ @jpelgrom
/homeassistant/components/lg_infrared/ @abmantis
/tests/components/lg_infrared/ @abmantis
/homeassistant/components/lg_infrared/ @home-assistant/core
/tests/components/lg_infrared/ @home-assistant/core
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
@@ -1047,8 +1036,6 @@ CLAUDE.md @home-assistant/core
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/marantz_infrared/ @balloob
/tests/components/marantz_infrared/ @balloob
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/matrix/ @PaarthShah
@@ -1311,8 +1298,6 @@ CLAUDE.md @home-assistant/core
/tests/components/osoenergy/ @osohotwateriot
/homeassistant/components/otbr/ @home-assistant/core
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ouman_eh_800/ @Markus98
/tests/components/ouman_eh_800/ @Markus98
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
@@ -1323,8 +1308,6 @@ CLAUDE.md @home-assistant/core
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
/tests/components/p1_monitor/ @klaasnicolaas
/homeassistant/components/paj_gps/ @skipperro
/tests/components/paj_gps/ @skipperro
/homeassistant/components/palazzetti/ @dotvav
/tests/components/palazzetti/ @dotvav
/homeassistant/components/panel_custom/ @home-assistant/frontend
@@ -1536,8 +1519,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/sabnzbd/ @shaiu @jpbede
/tests/components/sabnzbd/ @shaiu @jpbede
/homeassistant/components/saj/ @fredericvl
/homeassistant/components/samsung_infrared/ @lmaertin
/tests/components/samsung_infrared/ @lmaertin
/homeassistant/components/samsungtv/ @chemelli74 @epenet
/tests/components/samsungtv/ @chemelli74 @epenet
/homeassistant/components/sanix/ @tomaszsluszniak
@@ -2036,8 +2017,6 @@ CLAUDE.md @home-assistant/core
/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG
/homeassistant/components/xiaomi_tv/ @simse
/homeassistant/components/xmpp/ @fabaff @flowolf
/homeassistant/components/xthings_cloud/ @XthingsJacobs
/tests/components/xthings_cloud/ @XthingsJacobs
/homeassistant/components/yale/ @bdraco
/tests/components/yale/ @bdraco
/homeassistant/components/yale_smart_alarm/ @gjohansson-ST
@@ -2056,8 +2035,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/yi/ @bachya
/homeassistant/components/yolink/ @matrixd2
/tests/components/yolink/ @matrixd2
/homeassistant/components/yoto/ @cdnninja @piitaya
/tests/components/yoto/ @cdnninja @piitaya
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
@@ -2070,8 +2047,8 @@ CLAUDE.md @home-assistant/core
/tests/components/zeroconf/ @bdraco
/homeassistant/components/zerproc/ @emlove
/tests/components/zerproc/ @emlove
/homeassistant/components/zeversolar/ @kvanzuijlen @mhuiskes
/tests/components/zeversolar/ @kvanzuijlen @mhuiskes
/homeassistant/components/zeversolar/ @kvanzuijlen
/tests/components/zeversolar/ @kvanzuijlen
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon
Generated
+2 -2
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
# Partly generated by hassfest.
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
@@ -26,7 +26,7 @@ WORKDIR /usr/src
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:1.9.14@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
## Setup Home Assistant Core dependencies
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
+4 -6
View File
@@ -73,12 +73,10 @@ async def auth_manager_from_config(
provider_hash[key] = provider
if isinstance(provider, HassAuthProvider):
# Can be removed in 2026.7 with the legacy mode of
# homeassistant auth provider.
# We need to initialize the provider to create the repair
# if needed as otherwise the provider will be initialized
# on first use, which could be rare as users don't
# frequently change auth settings
# Can be removed in 2026.7 with the legacy mode of homeassistant auth provider
# We need to initialize the provider to create the repair if needed as otherwise
# the provider will be initialized on first use, which could be rare as users
# don't frequently change auth settings
await provider.async_initialize()
if module_configs:
@@ -120,10 +120,9 @@ class Data:
if self.normalize_username(username, force_normalize=True) != username:
logging.getLogger(__name__).warning(
(
"Home Assistant auth provider is running in"
" legacy mode because we detected usernames"
" that are normalized (lowercase and without"
" spaces). Please change the username: '%s'."
"Home Assistant auth provider is running in legacy mode "
"because we detected usernames that are normalized (lowercase and without spaces)."
" Please change the username: '%s'."
),
username,
)
@@ -140,9 +139,7 @@ class Data:
severity=ir.IssueSeverity.WARNING,
translation_key="homeassistant_provider_not_normalized_usernames",
translation_placeholders={
"usernames": (
f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
)
"usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
},
learn_more_url="homeassistant://config/users",
)
+2 -6
View File
@@ -60,10 +60,7 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
def _clear_configuration_directory(config_dir: Path, keep: Iterable[str]) -> None:
"""Delete all files and directories in the config dir.
Entries in the keep list are preserved.
"""
"""Delete all files and directories in the config directory except entries in the keep list."""
keep_paths = [config_dir.joinpath(path) for path in keep]
entries_to_remove = sorted(
entry for entry in config_dir.iterdir() if entry not in keep_paths
@@ -104,8 +101,7 @@ def _extract_backup(
)
) > HA_VERSION:
raise ValueError(
f"You need at least Home Assistant version"
f" {backup_meta_version} to restore this backup"
f"You need at least Home Assistant version {backup_meta_version} to restore this backup"
)
with securetar.SecureTarFile(
+5 -10
View File
@@ -17,8 +17,7 @@ from time import monotonic
from typing import TYPE_CHECKING, Any
# Import cryptography early since import openssl is not thread-safe
# _frozen_importlib._DeadlockError: deadlock detected by
# _ModuleLock('cryptography.hazmat.backends.openssl.backend')
# _frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cryptography.hazmat.backends.openssl.backend')
import cryptography.hazmat.backends.openssl.backend # noqa: F401
import voluptuous as vol
import yarl
@@ -166,14 +165,10 @@ FRONTEND_INTEGRATIONS = {
# visible in frontend
"frontend",
}
# Stage 0 is divided into substages. Each substage has a name,
# a set of integrations and a timeout.
# The substage containing recorder should have no timeout, as it
# could cancel a database migration.
# Recorder freezes "recorder" timeout during a migration, but it
# does not freeze other timeouts.
# If we add timeouts to the frontend substages, we should make sure
# they don't apply in recovery mode.
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
# The substage containing recorder should have no timeout, as it could cancel a database migration.
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
STAGE_0_INTEGRATIONS = (
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "marantz",
"name": "Marantz",
"integrations": ["marantz", "marantz_infrared"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "mitsubishi",
"name": "Mitsubishi",
"integrations": ["melcloud", "mitsubishi_comfort"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "samsung",
"name": "Samsung",
"integrations": ["familyhub", "samsung_infrared", "samsungtv", "syncthru"]
"integrations": ["familyhub", "samsungtv", "syncthru"]
}
@@ -44,7 +44,6 @@ def _change_setting(call: ServiceCall) -> None:
try:
_get_abode_system(call.hass).abode.set_setting(setting, value)
# pylint: disable-next=home-assistant-action-swallowed-exception
except AbodeException as ex:
LOGGER.warning(ex)
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.8.0"]
"requirements": ["serialx==1.7.1"]
}
@@ -79,12 +79,6 @@
"exceptions": {
"config_entry_not_loaded": {
"message": "Config entry not loaded."
},
"error_while_turn_off": {
"message": "An error occurred while turning off AdGuard Home switch."
},
"error_while_turn_on": {
"message": "An error occurred while turning on AdGuard Home switch."
}
},
"services": {
+5 -12
View File
@@ -9,11 +9,10 @@ from adguardhome import AdGuardHome, AdGuardHomeError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdGuardConfigEntry, AdGuardData
from .const import DOMAIN
from .const import DOMAIN, LOGGER
from .entity import AdGuardHomeEntity
SCAN_INTERVAL = timedelta(seconds=10)
@@ -117,23 +116,17 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
"""Turn off the switch."""
try:
await self.entity_description.turn_off_fn(self.adguard)()
except AdGuardHomeError as err:
except AdGuardHomeError:
LOGGER.error("An error occurred while turning off AdGuard Home switch")
self._attr_available = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_while_turn_off",
) from err
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
try:
await self.entity_description.turn_on_fn(self.adguard)()
except AdGuardHomeError as err:
except AdGuardHomeError:
LOGGER.error("An error occurred while turning on AdGuard Home switch")
self._attr_available = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_while_turn_on",
) from err
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
+1 -2
View File
@@ -106,8 +106,7 @@ class AdsLight(AdsEntity, LightEntity):
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
# Set color temperature range
# (static config values take precedence over defaults)
# Set color temperature range (static config values take precedence over defaults)
if ads_var_color_temp_kelvin is not None:
self._attr_min_color_temp_kelvin = (
min_color_temp_kelvin
@@ -171,8 +171,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the current target temperature."""
# If the system is in MyZone mode, and a zone is set,
# return that temperature instead.
# If the system is in MyZone mode, and a zone is set, return that temperature instead.
if self._myzone and self.preset_mode == ADVANTAGE_AIR_MYZONE:
return self._myzone["setTemp"]
return self._ac["setTemp"]
@@ -297,11 +296,7 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
@property
def hvac_action(self) -> HVACAction | None:
"""Return the HVAC action.
Inherits from master AC if zone is open but idle if air
is <= 5%.
"""
"""Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%."""
if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF:
return HVACAction.OFF
master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF)
@@ -59,8 +59,6 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
schema = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
# Name field is no longer allowed in config flow schemas
# pylint: disable-next=home-assistant-config-flow-name-field
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
vol.Optional(
CONF_LATITUDE, default=self.hass.config.latitude
@@ -56,7 +56,6 @@ async def async_setup_entry(
)
async_dispatcher_send(hass, UPDATE_TOPIC)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_ADD_TRACKING,
@@ -72,7 +71,6 @@ async def async_setup_entry(
)
async_dispatcher_send(hass, UPDATE_TOPIC)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_TRACKING,
@@ -1,25 +0,0 @@
"""The aidot integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AidotConfigEntry, AidotDeviceManagerCoordinator
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
"""Set up aidot from a config entry."""
coordinator = AidotDeviceManagerCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
return True
async def async_unload_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.async_cleanup()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,66 +0,0 @@
"""Config flow for Aidot integration."""
from typing import Any
from aidot.client import AidotClient
from aidot.const import CONF_ID, DEFAULT_COUNTRY_CODE, SUPPORTED_COUNTRY_CODES
from aidot.exceptions import AidotUserOrPassIncorrect
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(
CONF_COUNTRY_CODE,
default=DEFAULT_COUNTRY_CODE,
): selector.CountrySelector(
selector.CountrySelectorConfig(
countries=SUPPORTED_COUNTRY_CODES,
)
),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AidotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle aidot config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
client = AidotClient(
session=async_get_clientsession(self.hass),
country_code=user_input[CONF_COUNTRY_CODE],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
try:
login_info = await client.async_post_login()
except AidotUserOrPassIncorrect:
errors["base"] = "invalid_auth"
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
if not errors:
await self.async_set_unique_id(login_info[CONF_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{user_input[CONF_USERNAME]} {user_input[CONF_COUNTRY_CODE]}",
data=login_info,
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
-3
View File
@@ -1,3 +0,0 @@
"""Constants for the aidot integration."""
DOMAIN = "aidot"
@@ -1,163 +0,0 @@
"""Coordinator for Aidot."""
from datetime import timedelta
import logging
from aidot.client import AidotClient
from aidot.const import (
CONF_ACCESS_TOKEN,
CONF_AES_KEY,
CONF_DEVICE_LIST,
CONF_ID,
CONF_TYPE,
)
from aidot.device_client import DeviceClient, DeviceStatusData
from aidot.exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
type AidotConfigEntry = ConfigEntry[AidotDeviceManagerCoordinator]
_LOGGER = logging.getLogger(__name__)
UPDATE_DEVICE_LIST_INTERVAL = timedelta(hours=6)
class AidotDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceStatusData]):
"""Class to manage Aidot data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: AidotConfigEntry,
device_client: DeviceClient,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=None,
)
self.device_client = device_client
async def _async_setup(self) -> None:
"""Set up the coordinator."""
self.device_client.on_status_update = self._handle_status_update
def _handle_status_update(self, status: DeviceStatusData) -> None:
"""Handle status callback."""
self.async_set_updated_data(status)
async def _async_update_data(self) -> DeviceStatusData:
"""Return current status."""
return self.device_client.status
class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching Aidot data."""
config_entry: AidotConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AidotConfigEntry,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=UPDATE_DEVICE_LIST_INTERVAL,
)
self.client = AidotClient(
session=async_get_clientsession(hass),
token=config_entry.data,
)
self.client.set_token_fresh_cb(self.token_fresh_cb)
self.device_coordinators: dict[str, AidotDeviceUpdateCoordinator] = {}
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.async_auto_login()
except AidotUserOrPassIncorrect as error:
raise ConfigEntryError from error
async def _async_update_data(self) -> None:
"""Update data async."""
try:
data = await self.client.async_get_all_device()
except AidotAuthFailed as error:
raise ConfigEntryError from error
current_devices = {
device[CONF_ID]: device
for device in data[CONF_DEVICE_LIST]
if (
device[CONF_TYPE] == "light"
and CONF_AES_KEY in device
and device[CONF_AES_KEY][0] is not None
)
}
removed_ids = set(self.device_coordinators) - set(current_devices)
for dev_id in removed_ids:
coordinator = self.device_coordinators.pop(dev_id)
coordinator.device_client.on_status_update = None
if removed_ids:
self._purge_deleted_lists()
for dev_id, device in current_devices.items():
if dev_id not in self.device_coordinators:
device_client = self.client.get_device_client(device)
device_coordinator = AidotDeviceUpdateCoordinator(
self.hass, self.config_entry, device_client
)
await device_coordinator.async_config_entry_first_refresh()
self.device_coordinators[dev_id] = device_coordinator
async def async_cleanup(self) -> None:
"""Perform cleanup actions."""
for coordinator in self.device_coordinators.values():
coordinator.device_client.on_status_update = None
await self.client.async_cleanup()
def token_fresh_cb(self) -> None:
"""Update token."""
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.client.login_info.copy()
)
async def async_auto_login(self) -> None:
"""Async auto login."""
if self.client.login_info.get(CONF_ACCESS_TOKEN) is None:
await self.client.async_post_login()
def _purge_deleted_lists(self) -> None:
"""Purge device entries of deleted lists."""
device_reg = dr.async_get(self.hass)
identifiers = {
(
DOMAIN,
device_coordinator.device_client.info.dev_id,
)
for device_coordinator in self.device_coordinators.values()
}
for device in dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id
)
-122
View File
@@ -1,122 +0,0 @@
"""Support for Aidot lights."""
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGBW_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AidotConfigEntry, AidotDeviceUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: AidotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Light."""
coordinator = entry.runtime_data
async_add_entities(
AidotLight(device_coordinator)
for device_coordinator in coordinator.device_coordinators.values()
)
class AidotLight(CoordinatorEntity[AidotDeviceUpdateCoordinator], LightEntity):
"""Representation of a Aidot Wi-Fi Light."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, coordinator: AidotDeviceUpdateCoordinator) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.device_client.info.dev_id
if hasattr(coordinator.device_client.info, "cct_max"):
self._attr_max_color_temp_kelvin = coordinator.device_client.info.cct_max
if hasattr(coordinator.device_client.info, "cct_min"):
self._attr_min_color_temp_kelvin = coordinator.device_client.info.cct_min
model_id = coordinator.device_client.info.model_id
manufacturer = model_id.split(".")[0]
model = model_id[len(manufacturer) + 1 :]
mac = coordinator.device_client.info.mac
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer=manufacturer,
model=model,
name=coordinator.device_client.info.name,
hw_version=coordinator.device_client.info.hw_version,
)
if coordinator.device_client.info.enable_rgbw:
self._attr_color_mode = ColorMode.RGBW
self._attr_supported_color_modes = {ColorMode.RGBW, ColorMode.COLOR_TEMP}
elif coordinator.device_client.info.enable_cct:
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
else:
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._update_status()
def _update_status(self) -> None:
"""Update light status from coordinator data."""
self._attr_is_on = self.coordinator.data.on
self._attr_brightness = self.coordinator.data.dimming
self._attr_color_temp_kelvin = self.coordinator.data.cct
self._attr_rgbw_color = self.coordinator.data.rgbw
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.online
@callback
def _handle_coordinator_update(self) -> None:
"""Update."""
self._update_status()
super()._handle_coordinator_update()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on, applying brightness, color temperature, RGBW, or plain on."""
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
await self.coordinator.device_client.async_set_brightness(brightness)
self.coordinator.data.dimming = brightness
self._attr_brightness = brightness
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
await self.coordinator.device_client.async_set_cct(color_temp_kelvin)
self.coordinator.data.cct = color_temp_kelvin
self._attr_color_temp_kelvin = color_temp_kelvin
self._attr_color_mode = ColorMode.COLOR_TEMP
elif ATTR_RGBW_COLOR in kwargs:
rgbw_color = kwargs.get(ATTR_RGBW_COLOR)
await self.coordinator.device_client.async_set_rgbw(rgbw_color)
self.coordinator.data.rgbw = rgbw_color
self._attr_rgbw_color = rgbw_color
self._attr_color_mode = ColorMode.RGBW
else:
await self.coordinator.device_client.async_turn_on()
self.coordinator.data.on = True
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self.coordinator.device_client.async_turn_off()
self.coordinator.data.on = False
self._attr_is_on = False
self.async_write_ha_state()
@@ -1,11 +0,0 @@
{
"domain": "aidot",
"name": "AiDot",
"codeowners": ["@s1eedz", "@HongBryan"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aidot",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-aidot==0.3.53"]
}
@@ -1,67 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not register any events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
entity-disabled-by-default: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -1,25 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"user": {
"data": {
"country_code": "Country",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"country_code": "The country selected by AiDot app when logging in",
"password": "Password for logging in through AiDot app",
"username": "Account logged in through AiDot app"
}
}
}
}
}
+36 -58
View File
@@ -68,24 +68,20 @@ TRIGGERS: dict[str, type[Trigger]] = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"co_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
)
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"ozone_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
)
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
@@ -96,16 +92,14 @@ TRIGGERS: dict[str, type[Trigger]] = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
)
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
@@ -116,60 +110,44 @@ TRIGGERS: dict[str, type[Trigger]] = {
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"voc_ratio_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
)
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
)
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"no2_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
)
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
"so2_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
)
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor triggers without unit conversion (single-unit device classes)
"co2_changed": make_entity_numerical_state_changed_trigger(
+1 -2
View File
@@ -184,8 +184,7 @@ async def async_setup_entry(
(
AirlySensor(coordinator, name, description)
for description in SENSOR_TYPES
# When we use the nearest method, we are not sure
# which sensors are available
# When we use the nearest method, we are not sure which sensors are available
if coordinator.data.get(description.key)
),
False,
@@ -1,67 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: done
comment: Reports are polled every 30 minutes so newly published hourly AirNow reports are picked up promptly.
brands: done
common-modules: done
config-flow-test-coverage: todo
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: done
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class:
status: todo
comment: The ozone sensor can still use the ozone device class.
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
stale-devices: todo
repair-issues: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
+1 -3
View File
@@ -75,9 +75,7 @@ def aqi_extra_attrs(data: dict[str, Any]) -> dict[str, Any]:
ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION],
ATTR_LEVEL: data[ATTR_API_AQI_LEVEL],
ATTR_TIME: parser.parse(
f"{data[ATTR_API_REPORT_DATE]} "
f"{data[ATTR_API_REPORT_HOUR]}:00 "
f"{data[ATTR_API_REPORT_TZ]}",
f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}:00 {data[ATTR_API_REPORT_TZ]}",
tzinfos=US_TZ_OFFSETS,
).isoformat(),
}
@@ -46,9 +46,6 @@
"init": {
"data": {
"radius": "Station radius (miles)"
},
"data_description": {
"radius": "The radius in miles around your location to search for reporting stations."
}
}
}
@@ -83,7 +83,6 @@ class AirobotButton(AirobotEntity, ButtonEntity):
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
# pylint: disable-next=home-assistant-action-swallowed-exception
except AirobotConnectionError, AirobotTimeoutError:
# Connection errors during reboot are expected as device restarts
pass
@@ -2,6 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar
from airos.data import AirOSDataBaseClass
@@ -19,10 +20,13 @@ from .entity import AirOSEntity
PARALLEL_UPDATES = 0
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
@dataclass(frozen=True, kw_only=True)
class AirOSBinarySensorEntityDescription[AirOSDataModel: AirOSDataBaseClass](
class AirOSBinarySensorEntityDescription(
BinarySensorEntityDescription,
Generic[AirOSDataModel],
):
"""Describe an AirOS binary sensor."""
+4 -3
View File
@@ -3,6 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Generic, TypeVar
from airos.data import (
AirOSDataBaseClass,
@@ -40,11 +41,11 @@ WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
PARALLEL_UPDATES = 0
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
@dataclass(frozen=True, kw_only=True)
class AirOSSensorEntityDescription[AirOSDataModel: AirOSDataBaseClass](
SensorEntityDescription
):
class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]):
"""Describe an AirOS sensor."""
value_fn: Callable[[AirOSDataModel], StateType]
+1 -2
View File
@@ -54,8 +54,7 @@ class AirQCoordinator(DataUpdateCoordinator):
"""Fetch the data from the device."""
if "name" not in self.device_info:
_LOGGER.debug(
"'name' not found in AirQCoordinator.device_info,"
" fetching from the device"
"'name' not found in AirQCoordinator.device_info, fetching from the device"
)
info = await self.airq.fetch_device_info()
self.device_info.update(
@@ -158,8 +158,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
await self._airtouch.SetCoolingModeForAc(
self._ac_number, HA_STATE_TO_AT[hvac_mode]
)
# in case it isn't already, unless the HVAC mode was off,
# then the ac should be on
# in case it isn't already, unless the HVAC mode was off, then the ac should be on
await self.async_turn_on()
self._unit = self._airtouch.GetAcs()[self._ac_number]
_LOGGER.debug("Setting operation mode of %s to %s", self._ac_number, hvac_mode)
@@ -247,8 +246,7 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac target hvac state."""
# there are other power states that aren't 'on' but still
# count as on (eg. 'Turbo')
# there are other power states that aren't 'on' but still count as on (eg. 'Turbo')
is_off = self._unit.PowerState == "Off"
if is_off:
return HVACMode.OFF
@@ -178,8 +178,7 @@ class Airtouch5AC(Airtouch5ClimateEntity):
if ability.supports_fan_speed_intelligent_auto:
self._attr_fan_modes.append(FAN_INTELLIGENT_AUTO)
# We can have different setpoints for heat cool,
# we expose the lowest low and highest high
# We can have different setpoints for heat cool, we expose the lowest low and highest high
self._attr_min_temp = min(
ability.min_cool_set_point, ability.min_heat_set_point
)
@@ -291,8 +290,7 @@ class Airtouch5Zone(Airtouch5ClimateEntity):
manufacturer="Polyaire",
model="AirTouch 5",
)
# We can have different setpoints for heat and cool,
# we expose the lowest low and highest high
# We can have different setpoints for heat and cool, we expose the lowest low and highest high
self._attr_min_temp = min(ac.min_cool_set_point, ac.min_heat_set_point)
self._attr_max_temp = max(ac.max_cool_set_point, ac.max_heat_set_point)
@@ -34,9 +34,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors = {"base": "cannot_connect"}
else:
# Uses the host/IP value from CONF_HOST as unique ID,
# which is no longer allowed
# pylint: disable-next=home-assistant-unique-id-ip-based
# Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed
# pylint: disable-next=hass-unique-id-ip-based
await self.async_set_unique_id(user_input[CONF_HOST])
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -75,7 +75,7 @@ def async_get_cloud_api_update_interval(
def async_get_cloud_coordinators_by_api_key(
hass: HomeAssistant, api_key: str
) -> list[AirVisualDataUpdateCoordinator]:
"""Get all coordinators related to a particular API key."""
"""Get all AirVisualDataUpdateCoordinator objects related to a particular API key."""
return [
entry.runtime_data
for entry in hass.config_entries.async_entries(DOMAIN)
@@ -24,7 +24,7 @@ class AsyncConfigFlowAuth(Auth):
class AsyncConfigEntryAuth(Auth):
"""Provide Aladdin Connect Genie auth tied to an OAuth2 config entry."""
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
def __init__(
self,
@@ -354,9 +354,8 @@ def _validate_zone_input(zone_input: dict[str, Any] | None) -> dict[str, str]:
def _fix_input_types(zone_input: dict[str, Any]) -> dict[str, Any]:
"""Convert necessary keys to int.
Since ConfigFlow inputs of type int cannot default to an empty
string, we collect the values below as strings and then convert
them to ints.
Since ConfigFlow inputs of type int cannot default to an empty string, we collect the values below as
strings and then convert them to ints.
"""
for key in (CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN):
+1 -4
View File
@@ -1255,10 +1255,7 @@ async def async_api_set_mode(
service = water_heater.SERVICE_SET_OPERATION_MODE
data[water_heater.ATTR_OPERATION_MODE] = operation_mode
else:
msg = (
f"Entity '{entity.entity_id}' does not support"
f" Operation mode '{operation_mode}'"
)
msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'"
raise AlexaInvalidValueError(msg)
# Cover Position
+1 -2
View File
@@ -224,8 +224,7 @@ def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
resolved_data["id"] = possible_values[0]["id"]
# If there is only one match use the resolved value, otherwise the
# resolution cannot be determined, so use the spoken slot
# value and empty string as id
# resolution cannot be determined, so use the spoken slot value and empty string as id
if len(possible_values) == 1:
resolved_data["value"] = possible_values[0]["name"]
else:
+3 -36
View File
@@ -2,7 +2,6 @@
from asyncio import timeout
from collections.abc import Mapping
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
@@ -12,12 +11,7 @@ from uuid import uuid4
import aiohttp
from homeassistant.components import event
from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -57,25 +51,6 @@ DEFAULT_TIMEOUT = 10
TO_REDACT = {"correlationToken", "token"}
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
"""Check if doorbell event timestamp is valid."""
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
try:
timestamp = datetime.fromisoformat(event_state)
except ValueError:
_LOGGER.debug(
"Unable to parse ISO timestamp from state for %s. Got %s",
entity_id,
event_state,
)
return False
else:
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
return True
return False
class AlexaDirective:
"""An incoming Alexa directive."""
@@ -340,17 +315,9 @@ async def async_enable_proactive_mode(
if should_doorbell:
old_state = data["old_state"]
if (
new_state.domain == event.DOMAIN
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
and (old_state is None or old_state.state != new_state.state)
) or (
if new_state.domain == event.DOMAIN or (
new_state.state == STATE_ON
and (
old_state is None
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
)
and (old_state is None or old_state.state != STATE_ON)
):
await async_send_doorbell_event_message(
hass, smart_home_config, alexa_changed_entity
@@ -44,10 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version < 3:
if CONF_SITE in entry.data:
# Site in data (wrong place), just move to login data
@@ -27,6 +27,9 @@ COUNTRY_DOMAINS = {
"za": "co.za",
}
CATEGORY_SENSORS = "sensors"
CATEGORY_NOTIFICATIONS = "notifications"
# Map service translation keys to Alexa API
INFO_SKILLS_MAPPING = {
"calendar_today": "Alexa.Calendar.PlayToday",
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.7.0"]
"requirements": ["aioamazondevices==13.4.3"]
}
@@ -29,7 +29,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_remove_unsupported_notification_sensors
@@ -37,44 +38,30 @@ from .utils import async_remove_unsupported_notification_sensors
PARALLEL_UPDATES = 0
type ValueFn = Callable[
[AmazonDevice, str, AmazonDevicesCoordinator], StateType | datetime
]
@dataclass(frozen=True, kw_only=True)
class AmazonBaseEntityDescription(SensorEntityDescription):
"""Shared Amazon Devices entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online
)
value_fn: ValueFn
@dataclass(frozen=True, kw_only=True)
class AmazonSensorEntityDescription(AmazonBaseEntityDescription):
class AmazonSensorEntityDescription(SensorEntityDescription):
"""Amazon Devices sensor entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
)
value_fn: ValueFn = lambda device, key, _: device.sensors[key].value
category: str = CATEGORY_SENSORS
@dataclass(frozen=True, kw_only=True)
class AmazonNotificationEntityDescription(AmazonBaseEntityDescription):
class AmazonNotificationEntityDescription(SensorEntityDescription):
"""Amazon Devices notification entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online
and (notification := device.notifications.get(key)) is not None
and notification.next_occurrence is not None
)
value_fn: ValueFn = lambda device, key, _: device.notifications[key].next_occurrence
category: str = CATEGORY_NOTIFICATIONS
SENSORS: Final = (
@@ -206,11 +193,11 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity):
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.device,
self.entity_description.key,
self.coordinator,
)
# Sensors
if self.entity_description.category == CATEGORY_SENSORS:
return self.device.sensors[self.entity_description.key].value
# Notifications
return self.device.notifications[self.entity_description.key].next_occurrence
@property
def available(self) -> bool:
@@ -21,8 +21,7 @@ API_URL = "https://app.amber.com.au/developers"
def generate_site_selector_name(site: Site) -> str:
"""Generate the name to show in the site drop down in the configuration flow."""
# For some reason the generated API key returns this as any,
# not a string. Thanks pydantic
# For some reason the generated API key returns this as any, not a string. Thanks pydantic
nmi = str(site.nmi)
if site.status == SiteStatus.CLOSED:
if site.closed_on is None:
@@ -48,7 +48,7 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
class AmberUpdateCoordinator(DataUpdateCoordinator):
"""Coordinator in charge of downloading site data for all sensors."""
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
config_entry: AmberConfigEntry
@@ -14,10 +14,7 @@ DESCRIPTOR_MAP: dict[str, str] = {
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
"""Return the snake case versions of descriptor names.
Returns None if the name is not recognized.
"""
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
if descriptor in DESCRIPTOR_MAP:
return DESCRIPTOR_MAP[descriptor]
return None
@@ -26,5 +26,4 @@ def get_station_name(station: dict[str, Any]) -> str:
.get(API_STATION_LOCATION)
)
station_type = station.get(API_LAST_DATA, {}).get(API_STATION_TYPE)
separator = "" if location is None or station_type is None else " "
return f"{location}{separator}{station_type}"
return f"{location}{'' if location is None or station_type is None else ' '}{station_type}"
@@ -192,8 +192,7 @@ class AmcrestBinarySensor(BinarySensorEntity):
if self._api.available:
# Send a command to the camera to test if we can still communicate with it.
# Override of Http.async_command() in __init__.py will
# set self._api.available
# Override of Http.async_command() in __init__.py will set self._api.available
# accordingly.
with suppress(AmcrestError):
await self._api.async_current_time
+2 -4
View File
@@ -461,8 +461,7 @@ class AmcrestCam(Camera):
async def _async_set_recording(self, enable: bool) -> None:
rec_mode = {"Automatic": 0, "Manual": 1}
# The property has a str type, but setter has int type,
# which causes mypy confusion
# The property has a str type, but setter has int type, which causes mypy confusion
await self._api.async_set_record_mode(
rec_mode["Manual" if enable else "Automatic"]
)
@@ -480,8 +479,7 @@ class AmcrestCam(Camera):
return await self._api.async_is_motion_detector_on()
async def _async_set_motion_detection(self, enable: bool) -> None:
# The property has a str type, but setter has bool type,
# which causes mypy confusion
# The property has a str type, but setter has bool type, which causes mypy confusion
await self._api.async_set_motion_detection(enable)
async def _async_enable_motion_detection(self, enable: bool) -> None:
+20 -33
View File
@@ -3,7 +3,6 @@
import asyncio
from asyncio import timeout
from collections.abc import Awaitable, Callable, Iterable, Mapping
import contextlib
from dataclasses import asdict as dataclass_asdict, dataclass, field
from datetime import datetime
import random
@@ -298,24 +297,20 @@ class Analytics:
if stored:
self._data = AnalyticsData.from_dict(stored)
if self.supervisor and not self.onboarded:
# This may raise HassioNotReadyError if Supervisor was unreachable
# during setup of the Supervisor integration. That will fail setup
# of this integration. However there is no better option at this time
# since we need to get the diagnostic setting from Supervisor to correctly
# setup this integration and we can't raise ConfigEntryNotReady to
# trigger a retry from async_setup.
supervisor_info = hassio.get_supervisor_info(self._hass)
# User have not configured analytics, get this setting from the supervisor
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data.preferences[ATTR_DIAGNOSTICS] = True
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data.preferences[ATTR_DIAGNOSTICS] = False
if (
self.supervisor
and (supervisor_info := hassio.get_supervisor_info(self._hass)) is not None
):
if not self.onboarded:
# User have not configured analytics, get this setting from the supervisor
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data.preferences[ATTR_DIAGNOSTICS] = True
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data.preferences[ATTR_DIAGNOSTICS] = False
async def _save(self) -> None:
"""Save data."""
@@ -349,14 +344,9 @@ class Analytics:
await self._save()
if self.supervisor:
# get_supervisor_info was called during setup so we can't get here
# if it raised. The others may raise HassioNotReadyError if only some
# data was successfully fetched from Supervisor
supervisor_info = hassio.get_supervisor_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
operating_system_info = hassio.get_os_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
addons_info = hassio.get_addons_info(hass)
operating_system_info = hassio.get_os_info(hass) or {}
addons_info = hassio.get_addons_info(hass) or {}
system_info = await async_get_system_info(hass)
integrations = []
@@ -429,7 +419,7 @@ class Analytics:
integrations.append(integration.domain)
if addons_info:
if addons_info is not None:
supervisor_client = hassio.get_supervisor_client(hass)
installed_addons = await asyncio.gather(
*(supervisor_client.addons.addon_info(slug) for slug in addons_info)
@@ -612,8 +602,7 @@ class Analytics:
else:
LOGGER.warning(
"Unexpected status code %s when submitting"
" snapshot analytics to %s",
"Unexpected status code %s when submitting snapshot analytics to %s",
response.status,
url,
)
@@ -815,8 +804,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
if not isinstance(integration_config, AnalyticsModifications):
LOGGER.error( # type: ignore[unreachable]
"Calling async_modify_analytics for integration"
" '%s' did not return an AnalyticsConfig",
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
integration_domain,
)
integration_configs[integration_domain] = AnalyticsModifications(
@@ -830,8 +818,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
# We need to refer to other devices, for example in `via_device` field.
# We don't however send the original device ids outside of Home Assistant,
# instead we refer to devices by
# (integration_domain, index_in_integration_device_list).
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
device_id_mapping: dict[str, tuple[str, int]] = {}
# Fill out information about devices
@@ -15,7 +15,6 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT
from homeassistant.core import callback
from homeassistant.data_entry_flow import SectionConfig, section
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
ObjectSelector,
@@ -33,7 +32,6 @@ from .const import (
CONF_APPS,
CONF_EXCLUDE_UNNAMED_APPS,
CONF_GET_SOURCES,
CONF_MORE_OPTIONS,
CONF_SCREENCAP_INTERVAL,
CONF_STATE_DETECTION_RULES,
CONF_TURN_OFF_COMMAND,
@@ -99,22 +97,20 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
)
),
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Required(CONF_MORE_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_ADBKEY): str,
vol.Optional(CONF_ADB_SERVER_IP): str,
vol.Optional(
CONF_ADB_SERVER_PORT,
default=DEFAULT_ADB_SERVER_PORT,
): cv.port,
}
),
SectionConfig(collapsed=True),
),
},
)
if self.show_advanced_options:
data_schema = data_schema.extend(
{
vol.Optional(CONF_ADBKEY): str,
vol.Optional(CONF_ADB_SERVER_IP): str,
vol.Required(
CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT
): cv.port,
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
@@ -159,10 +155,6 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
error = None
if user_input is not None:
user_input = user_input.copy()
more_options = user_input.pop(CONF_MORE_OPTIONS, {})
user_input.update(more_options)
host = user_input[CONF_HOST]
adb_key = user_input.get(CONF_ADBKEY)
if CONF_ADB_SERVER_IP in user_input:
@@ -3,7 +3,6 @@
DOMAIN = "androidtv"
CONF_ADB_SERVER_IP = "adb_server_ip"
CONF_MORE_OPTIONS = "more_options"
CONF_ADB_SERVER_PORT = "adb_server_port"
CONF_ADBKEY = "adbkey"
CONF_APPS = "apps"
+4 -3
View File
@@ -94,9 +94,10 @@ def adb_decorator[_ADBDeviceT: AndroidTVEntity, **_P, _R](
# it doesn't happen over and over again.
if self.available:
_LOGGER.error(
"Unexpected exception executing an ADB"
" command. ADB connection re-establishing"
" attempt in the next update. Error: %s",
(
"Unexpected exception executing an ADB command. ADB connection"
" re-establishing attempt in the next update. Error: %s"
),
err,
)
@@ -281,7 +281,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
@adb_decorator()
async def service_download(self, device_path: str, local_path: str) -> None:
"""Download a file from your Android / Fire TV device."""
"""Download a file from your Android / Fire TV device to your Home Assistant instance."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
@@ -290,7 +290,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
@adb_decorator()
async def service_upload(self, device_path: str, local_path: str) -> None:
"""Upload a file to an Android / Fire TV device."""
"""Upload a file from your Home Assistant instance to an Android / Fire TV device."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
@@ -14,19 +14,12 @@
"step": {
"user": {
"data": {
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
"adb_server_port": "Port of the ADB server",
"adbkey": "Path to your ADB key file (leave empty to auto generate)",
"device_class": "The type of device",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"sections": {
"more_options": {
"data": {
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
"adb_server_port": "Port of the ADB server",
"adbkey": "Path to your ADB key file (leave empty to auto generate)"
},
"name": "More options"
}
}
}
}
@@ -41,9 +41,8 @@ async def async_setup_entry(
# The Android TV is hard reset or the certificate and key files were deleted.
raise ConfigEntryAuthFailed from exc
except (CannotConnect, ConnectionClosed, TimeoutError) as exc:
# The Android TV is network unreachable. Raise exception and
# let Home Assistant retry later. If device gets a new IP
# address the zeroconf flow will update the config.
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
# later. If device gets a new IP address the zeroconf flow will update the config.
raise ConfigEntryNotReady from exc
def reauth_needed() -> None:
@@ -107,10 +107,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def _async_start_pair(self) -> ConfigFlowResult:
"""Start pairing with the Android TV.
Navigate to the pair flow to enter the PIN shown on screen.
"""
"""Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen."""
self.api = create_api(self.hass, self.host, enable_ime=False)
await self.api.async_generate_cert_if_missing()
await self.api.async_start_pairing()
@@ -138,10 +135,9 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_start_pair()
except CannotConnect, ConnectionClosed:
# Device doesn't respond to the specified host. Abort.
# If we are in the user flow we could go back
# to the user step to allow them to enter a
# new IP address but we cannot do that for the
# zeroconf flow. Simpler to abort for both.
# If we are in the user flow we could go back to the user step to allow
# them to enter a new IP address but we cannot do that for the zeroconf
# flow. Simpler to abort for both flows.
return self.async_abort(reason="cannot_connect")
else:
if self.source == SOURCE_REAUTH:
@@ -42,7 +42,7 @@ class AndroidTVRemoteBaseEntity(Entity):
@callback
def _is_available_updated(self, is_available: bool) -> None:
"""Update the state when the device is ready or unavailable."""
"""Update the state when the device is ready to receive commands or is unavailable."""
self._attr_available = is_available
self.async_write_ha_state()
@@ -65,8 +65,7 @@ class AndroidTVRemoteBaseEntity(Entity):
def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None:
"""Send a key press to Android TV.
This does not block; it buffers the data and arranges
for it to be sent out asynchronously.
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_key_command(key_code, direction)
@@ -78,8 +77,7 @@ class AndroidTVRemoteBaseEntity(Entity):
def _send_launch_app_command(self, app_link: str) -> None:
"""Launch an app on Android TV.
This does not block; it buffers the data and arranges
for it to be sent out asynchronously.
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_launch_app_command(app_link)
@@ -95,10 +95,8 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
continue
# Anglian Water stats are hourly, the read_at time
# is the time that the meter took the reading.
# We remove 1 hour from this so that the data is
# shown in the correct hour on the dashboards
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
if not parsed_read_at:
_LOGGER.debug(
@@ -132,9 +130,8 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
if not stats or not stats.get(usage_statistic_id):
_LOGGER.debug(
"Could not find existing statistics during"
" period lookup for %s, falling back to"
" last stored statistic",
"Could not find existing statistics during period lookup for %s, "
"falling back to last stored statistic",
usage_statistic_id,
)
allow_update_last_stored_hour = True
+1 -2
View File
@@ -43,8 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> boo
except NoDevicesFound as err:
# Can later setup successfully and spawn a repair.
raise ConfigEntryNotReady(
"No devices were found on the websocket, perhaps you"
" don't have any devices on this account?"
"No devices were found on the websocket, perhaps you don't have any devices on this account?"
) from err
except WebsocketFailure as err:
raise ConfigEntryNotReady("Failed connecting to the websocket.") from err
@@ -26,7 +26,8 @@ from homeassistant.const import (
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers import llm
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
NumberSelector,
@@ -49,8 +50,6 @@ from .const import (
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_FETCH,
CONF_WEB_FETCH_MAX_USES,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -453,19 +452,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): cv.positive_int,
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
vol.Optional(
CONF_WEB_FETCH,
default=DEFAULT[CONF_WEB_FETCH],
): bool,
vol.Optional(
CONF_WEB_FETCH_MAX_USES,
default=DEFAULT[CONF_WEB_FETCH_MAX_USES],
): cv.positive_int,
}
)
@@ -555,9 +546,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
{
vol.Optional(
CONF_WEB_SEARCH_CITY,
description=(
"Free text input for the city, e.g. `San Francisco`"
),
description="Free text input for the city, e.g. `San Francisco`",
): str,
vol.Optional(
CONF_WEB_SEARCH_REGION,
@@ -18,8 +18,6 @@ CONF_PROMPT_CACHING = "prompt_caching"
CONF_THINKING_BUDGET = "thinking_budget"
CONF_THINKING_EFFORT = "thinking_effort"
CONF_TOOL_SEARCH = "tool_search"
CONF_WEB_FETCH = "web_fetch"
CONF_WEB_FETCH_MAX_USES = "web_fetch_max_uses"
CONF_WEB_SEARCH = "web_search"
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
@@ -47,8 +45,6 @@ DEFAULT = {
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
CONF_WEB_FETCH: False,
CONF_WEB_FETCH_MAX_USES: 5,
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_USER_LOCATION: False,
CONF_WEB_SEARCH_MAX_USES: 5,
@@ -34,7 +34,7 @@ def model_alias(model_id: str) -> str:
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
"""Coordinator using different intervals after success and failure."""
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
client: anthropic.AsyncAnthropic
+20 -63
View File
@@ -17,6 +17,8 @@ from anthropic.types import (
Base64PDFSourceParam,
BashCodeExecutionToolResultBlock,
CitationsDelta,
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockContent,
@@ -68,9 +70,6 @@ from anthropic.types import (
ToolUseBlock,
ToolUseBlockParam,
Usage,
WebFetchTool20250910Param,
WebFetchTool20260209Param,
WebFetchToolResultBlock,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
@@ -98,12 +97,6 @@ from anthropic.types.tool_search_tool_result_block_param import (
Content as ToolSearchToolResultBlockParamContentParam,
)
from anthropic.types.tool_use_block import Caller
from anthropic.types.web_fetch_tool_result_block import (
Content as WebFetchToolResultBlockContent,
)
from anthropic.types.web_fetch_tool_result_block_param import (
Content as WebFetchToolResultBlockParamContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -125,8 +118,6 @@ from .const import (
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_FETCH,
CONF_WEB_FETCH_MAX_USES,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -217,9 +208,17 @@ class ContentDetails:
"""Add a citation to the current detail."""
if not self.citation_details:
self.citation_details.append(CitationDetails())
self.citation_details[-1].citations.append(
cast(TextCitationParam, citation.to_dict())
)
citation_param: TextCitationParam | None = None
if isinstance(citation, CitationsWebSearchResultLocation):
citation_param = CitationWebSearchResultLocationParam(
type="web_search_result_location",
title=citation.title,
url=citation.url,
cited_text=citation.cited_text,
encrypted_index=citation.encrypted_index,
)
if citation_param:
self.citation_details[-1].citations.append(citation_param)
def delete_empty(self) -> None:
"""Delete empty citation details."""
@@ -290,15 +289,6 @@ def _convert_content( # noqa: C901
content.tool_result,
),
}
elif content.tool_name == "web_fetch":
tool_result_block = {
"type": "web_fetch_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
WebFetchToolResultBlockParamContentParam,
content.tool_result,
),
}
else:
tool_result_block = {
"type": "tool_result",
@@ -425,7 +415,6 @@ def _convert_content( # noqa: C901
id=tool_call.id,
name=cast(
Literal[
"web_fetch",
"web_search",
"code_execution",
"bash_code_execution",
@@ -439,7 +428,6 @@ def _convert_content( # noqa: C901
if tool_call.external
and tool_call.tool_name
in [
"web_fetch",
"web_search",
"code_execution",
"bash_code_execution",
@@ -464,8 +452,7 @@ def _convert_content( # noqa: C901
# If there is only one text block, simplify the content to a string
messages[-1]["content"] = messages[-1]["content"][0]["text"]
else:
# Note: We don't pass SystemContent here as it's
# passed to the API as the prompt
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unexpected_chat_log_content",
@@ -480,8 +467,7 @@ class AnthropicDeltaStream:
A typical stream of responses might look something like the following:
- RawMessageStartEvent with no content
- RawContentBlockStartEvent with an empty ThinkingBlock
(if extended thinking is enabled)
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
@@ -621,7 +607,6 @@ class AnthropicDeltaStream:
if isinstance(
content_block,
(
WebFetchToolResultBlock,
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
@@ -661,8 +646,7 @@ class AnthropicDeltaStream:
def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None:
"""Handle TextBlock."""
if ( # Do not start a new assistant content just for
# citations, concatenate consecutive blocks instead.
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
self._first_block
or (
not self._content_details.has_citations()
@@ -737,15 +721,13 @@ class AnthropicDeltaStream:
self,
tool_use_id: str,
tool_name: Literal[
"web_fetch_tool_result",
"web_search_tool_result",
"code_execution_tool_result",
"bash_code_execution_tool_result",
"text_editor_code_execution_tool_result",
"tool_search_tool_result",
],
content: WebFetchToolResultBlockContent
| WebSearchToolResultBlockContent
content: WebSearchToolResultBlockContent
| CodeExecutionToolResultBlockContent
| BashCodeExecutionToolResultBlockContent
| TextEditorCodeExecutionToolResultBlockContent
@@ -922,7 +904,6 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"GetLiveContext",
"code_execution",
"web_search",
"web_fetch",
]
system = chat_log.content[0]
@@ -996,12 +977,11 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
]
if options[CONF_CODE_EXECUTION]:
# The `web_search_20260209` and `web_fetch_20260209` tools
# automatically enable `code_execution_20260120` tool
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or (not options[CONF_WEB_SEARCH] and not options[CONF_WEB_FETCH])
or not options[CONF_WEB_SEARCH]
):
tools.append(
CodeExecutionTool20250825Param(
@@ -1039,28 +1019,6 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
}
tools.append(web_search)
if options[CONF_WEB_FETCH]:
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options[CONF_CODE_EXECUTION]
):
tools.append(
WebFetchTool20250910Param(
name="web_fetch",
type="web_fetch_20250910",
max_uses=options[CONF_WEB_FETCH_MAX_USES],
)
)
else:
tools.append(
WebFetchTool20260209Param(
name="web_fetch",
type="web_fetch_20260209",
max_uses=options[CONF_WEB_FETCH_MAX_USES],
)
)
# Handle attachments by adding them to the last user message
last_content = chat_log.content[-1]
if last_content.role == "user" and last_content.attachments:
@@ -1201,8 +1159,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
)
cast(list[MessageParam], model_args["messages"]).extend(new_messages)
except anthropic.AuthenticationError as err:
# Trigger coordinator to confirm the auth failure
# and trigger the reauth flow.
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
await coordinator.async_request_refresh()
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -7,7 +7,8 @@ import anthropic
from anthropic.resources.messages.messages import DEPRECATED_MODELS
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -40,7 +41,9 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._current_subentry_id = None
self._model_list_cache = None
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
async def async_step_init(
self, user_input: dict[str, str]
) -> data_entry_flow.FlowResult:
"""Handle the steps of a fix flow."""
if user_input.get(CONF_CHAT_MODEL):
self._async_update_current_subentry(user_input)
@@ -80,8 +80,6 @@
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
"web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch%]",
"web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch_max_uses%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
},
@@ -92,8 +90,6 @@
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
"web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch%]",
"web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch_max_uses%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
},
@@ -153,8 +149,6 @@
"thinking_effort": "Thinking effort",
"tool_search": "Enable tool search tool",
"user_location": "Include home location",
"web_fetch": "Enable web fetch",
"web_fetch_max_uses": "Maximum web fetches",
"web_search": "Enable web search",
"web_search_max_uses": "Maximum web searches"
},
@@ -165,8 +159,6 @@
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
"user_location": "Localize search results based on home location",
"web_fetch": "The web fetch tool allows Claude to retrieve full content from specified web pages and PDF documents to augment Claude's context with live web content",
"web_fetch_max_uses": "Limit the number of web fetches performed per response",
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
"web_search_max_uses": "Limit the number of searches performed per response"
},
+1 -4
View File
@@ -45,10 +45,7 @@ class AOSmithHotWaterPlusSelectEntity(AOSmithStatusEntity, SelectEntity):
@property
def suggested_object_id(self) -> str | None:
"""Override the suggested object id.
Makes '+' get converted to 'plus' in the entity id.
"""
"""Override the suggested object id to make '+' get converted to 'plus' in the entity id."""
return "hot_water_plus_level"
@property
@@ -54,8 +54,7 @@ class OnlineStatus(APCUPSdEntity, BinarySensorEntity):
"""Returns true if the UPS is online."""
# Check if ONLINE bit is set in STATFLAG.
key = self.entity_description.key.upper()
# The daemon could either report just a hex
# ("0x05000008"), or a hex with a "Status Flag"
# The daemon could either report just a hex ("0x05000008"), or a hex with a "Status Flag"
# suffix ("0x05000008 Status Flag") in older versions.
# Here we trim the suffix if it exists to support both.
flag = self.coordinator.data[key].removesuffix(" Status Flag")
+1 -2
View File
@@ -8,8 +8,7 @@ CONNECTION_TIMEOUT: int = 10
# Field name of last self test retrieved from apcupsd.
LAST_S_TEST: Final = "laststest"
# Mapping of deprecated sensor keys (as reported by apcupsd,
# lower-cased) to their deprecation
# Mapping of deprecated sensor keys (as reported by apcupsd, lower-cased) to their deprecation
# repair issue translation keys.
DEPRECATED_SENSORS: Final = {
"apc": "apc_deprecated",
@@ -27,7 +27,7 @@ type APCUPSdConfigEntry = ConfigEntry[APCUPSdCoordinator]
class APCUPSdData(dict[str, str]):
"""Store data about an APCUPSd and provide helper methods."""
"""Store data about an APCUPSd and provide a few helper methods for easier accesses."""
@property
def name(self) -> str | None:
@@ -45,9 +45,8 @@ class APCUPSdData(dict[str, str]):
def serial_no(self) -> str | None:
"""Return the unique serial number of the UPS, if available."""
sn = self.get("SERIALNO")
# We had user reports that some UPS models simply return
# "Blank" as serial number, in which case we fall back to
# `None` to indicate that it is actually not available.
# We had user reports that some UPS models simply return "Blank" as serial number, in
# which case we fall back to `None` to indicate that it is actually not available.
return None if sn == "Blank" else sn
@@ -86,11 +85,7 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
@property
def unique_device_id(self) -> str:
"""Return a unique ID of the device.
Uses the serial number if available, otherwise the
config entry ID.
"""
"""Return a unique ID of the device, which is the serial number (if available) or the config entry ID."""
return self.data.serial_no or self.config_entry.entry_id
@property
+12 -20
View File
@@ -473,16 +473,13 @@ async def async_setup_entry(
entities = []
# "laststest" is a special sensor that only appears when
# the APC UPS daemon has done a periodical (or manual) self
# test since last daemon restart. It might not be available
# when we set up the integration, and we do not know if it
# would ever be available. Here we add it anyway and mark it
# as unknown initially.
# "laststest" is a special sensor that only appears when the APC UPS daemon has done a
# periodical (or manual) self test since last daemon restart. It might not be available
# when we set up the integration, and we do not know if it would ever be available. Here we
# add it anyway and mark it as unknown initially.
#
# We also sort the resources to ensure the order of entities
# created is deterministic since "APCMODEL" and "MODEL"
# resources map to the same "Model" name.
# We also sort the resources to ensure the order of entities created is deterministic since
# "APCMODEL" and "MODEL" resources map to the same "Model" name.
for resource in sorted(available_resources | {LAST_S_TEST}):
if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
@@ -530,11 +527,9 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
def _update_attrs(self) -> None:
"""Update sensor attributes based on coordinator data."""
key = self.entity_description.key.upper()
# For most sensors the key will always be available for
# each refresh. However, some sensors (e.g., "laststest")
# will only appear after certain event occurs (e.g., a
# self test is performed) and may disappear again after
# certain event. So we mark the state as "unknown"
# For most sensors the key will always be available for each refresh. However, some sensors
# (e.g., "laststest") will only appear after certain event occurs (e.g., a self test is
# performed) and may disappear again after certain event. So we mark the state as "unknown"
# when it becomes unknown after such events.
if key not in self.coordinator.data:
self._attr_native_value = None
@@ -543,8 +538,7 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
data = self.coordinator.data[key]
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
# The date could be "N/A" for certain fields
# (e.g., XOFFBATT), indicating there is no value yet.
# The date could be "N/A" for certain fields (e.g., XOFFBATT), indicating there is no value yet.
if data == "N/A":
self._attr_native_value = None
return
@@ -552,8 +546,7 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
try:
self._attr_native_value = dateutil.parser.parse(data)
except dateutil.parser.ParserError, OverflowError:
# If parsing fails we should mark it as unknown,
# with a log for further debugging.
# If parsing fails we should mark it as unknown, with a log for further debugging.
_LOGGER.warning('Failed to parse date for %s: "%s"', key, data)
self._attr_native_value = None
return
@@ -585,8 +578,7 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
entity_registry = er.async_get(self.hass)
items = [
f"- [{entry.name or entry.original_name or entity_id}]"
f"(/config/{integration}/edit/"
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
f"(/config/{integration}/edit/{entry.unique_id or entity_id.split('.', 1)[-1]})"
for integration, entities in (
("automation", automations),
("script", scripts),
+1 -2
View File
@@ -406,8 +406,7 @@ class APIDomainServicesView(HomeAssistantView):
is ha.SupportsResponse.NONE
):
return self.json_message(
"Service does not support responses."
" Remove return_response from request.",
"Service does not support responses. Remove return_response from request.",
HTTPStatus.BAD_REQUEST,
)
elif (
@@ -300,10 +300,8 @@ class AppleTVManager(DeviceListener):
config_entry.title,
address,
)
# We no longer multicast scan for the device since as
# soon as async_step_zeroconf runs, it will update the
# address and reload the config entry when the device
# is found.
# We no longer multicast scan for the device since as soon as async_step_zeroconf runs,
# it will update the address and reload the config entry when the device is found.
return None
async def _connect(self, conf: AppleTV, raise_missing_credentials: bool) -> None:
@@ -5,7 +5,7 @@ from pyatv.interface import AppleTV, KeyboardListener
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -21,33 +21,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Apple TV binary sensor based on a config entry."""
# apple_tv config entries always have a unique id
manager = config_entry.runtime_data
added = False
cb: CALLBACK_TYPE
@callback
def setup_entities(atv: AppleTV) -> None:
nonlocal added
if added:
return
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
async_add_entities(
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
)
added = True
cb()
config_entry.async_on_unload(
async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
)
cb = async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
)
# The manager may have already connected (and dispatched SIGNAL_CONNECTED)
# before this platform was forwarded, in which case the signal above was
# missed; handle that case directly.
if manager.atv is not None:
setup_entities(manager.atv)
config_entry.async_on_unload(cb)
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
@@ -463,8 +463,7 @@ class AppleTvMediaPlayer(
"""Implement the websocket media browsing helper."""
if media_content_id == "apps" or (
# If we can't stream files or URLs, we can't browse media.
# In that case the `BROWSE_MEDIA` feature was added
# because of AppList/LaunchApp
# In that case the `BROWSE_MEDIA` feature was added because of AppList/LaunchApp
not self._is_feature_available(FeatureName.PlayUrl)
and not self._is_feature_available(FeatureName.StreamFile)
):
@@ -53,19 +53,18 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
self.state = State(client, zone)
self.update_in_progress = False
device_name = config_entry.title
name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id
unique_id_device = unique_id
if zone != 1:
unique_id_device += f"-{zone}"
device_name += f" Zone {zone}"
name += f" Zone {zone}"
self.device_name = device_name
self.device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id_device)},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=device_name,
name=name,
)
self.zone_unique_id = f"{unique_id}-{zone}"
@@ -1,36 +1,11 @@
"""Base entity for Arcam FMJ integration."""
from collections.abc import Callable, Coroutine
import functools
from typing import Any
from arcam.fmj import ConnectionFailed
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ArcamFmjCoordinator
def convert_exception[**_P, _R](
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
"""Convert a connection failure into a translated HomeAssistantError."""
@functools.wraps(func)
async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(*args, **kwargs)
except ConnectionFailed as exception:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="connection_failed"
) from exception
return _convert_exception
class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
"""Base entity for Arcam FMJ."""
@@ -1,9 +1,11 @@
"""Arcam media player."""
from collections.abc import Callable, Coroutine
import functools
import logging
from typing import Any
from arcam.fmj import SourceCodes
from arcam.fmj import ConnectionFailed, SourceCodes
from homeassistant.components.media_player import (
BrowseError,
@@ -16,12 +18,12 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, EVENT_TURN_ON
from .const import EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from .entity import ArcamFmjEntity, convert_exception
from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
@@ -39,6 +41,23 @@ async def async_setup_entry(
)
def convert_exception[**_P, _R](
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
"""Return decorator to convert a connection error into a home assistant error."""
@functools.wraps(func)
async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(*args, **kwargs)
except ConnectionFailed as exception:
raise HomeAssistantError(
f"Connection failed to device during {func}"
) from exception
return _convert_exception
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
"""Representation of a media device."""
@@ -60,17 +79,11 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the device.
``None`` is returned (surfaced as ``unknown``) when the device has
not yet reported a power state; this is distinct from a real
powered-off state and must not be collapsed to ``OFF``.
"""
power = self._state.get_power()
if power is None:
return None
return MediaPlayerState.ON if power else MediaPlayerState.OFF
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
if self._state.get_power():
return MediaPlayerState.ON
return MediaPlayerState.OFF
@convert_exception
async def async_mute_volume(self, mute: bool) -> None:
@@ -83,12 +96,9 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
"""Select a specific source."""
try:
value = SourceCodes[source]
except KeyError as exception:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_source",
translation_placeholders={"source": source},
) from exception
except KeyError:
_LOGGER.error("Unsupported source %s", source)
return
await self._state.set_source(value)
self.async_write_ha_state()
@@ -99,10 +109,8 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
try:
await self._state.set_decode_mode(sound_mode)
except (KeyError, ValueError) as exception:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_sound_mode",
translation_placeholders={"sound_mode": sound_mode},
raise HomeAssistantError(
f"Unsupported sound_mode {sound_mode}"
) from exception
self.async_write_ha_state()
@@ -166,7 +174,7 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
]
return BrowseMedia(
title=self.coordinator.device_name,
title="Arcam FMJ Receiver",
media_class=MediaClass.DIRECTORY,
media_content_id="root",
media_content_type="library",
@@ -185,11 +193,8 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
preset = int(media_id[7:])
await self._state.set_tuner_preset(preset)
else:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_media",
translation_placeholders={"media": media_id},
)
_LOGGER.error("Media %s is not supported", media_id)
return
@property
def source(self) -> str | None:
@@ -139,19 +139,5 @@
"name": "Incoming video vertical resolution"
}
}
},
"exceptions": {
"connection_failed": {
"message": "Connection failed to the device."
},
"unsupported_media": {
"message": "Unsupported media: {media}."
},
"unsupported_sound_mode": {
"message": "Unsupported sound mode: {sound_mode}."
},
"unsupported_source": {
"message": "Unsupported source: {source}."
}
}
}
@@ -44,10 +44,7 @@ class SpeechToTextError(PipelineError):
class DuplicateWakeUpDetectedError(WakeWordDetectionError):
"""Error when multiple voice assistants wake up at the same time.
Happens when multiple assistants detect the same wake word.
"""
"""Error when multiple voice assistants wake up at the same time (same wake word)."""
def __init__(self, wake_up_phrase: str) -> None:
"""Set error message."""
@@ -589,10 +589,7 @@ class PipelineRun:
"""Data tied to the conversation ID."""
_intent_agent_only = False
"""If request should only be handled by agent.
Ignores sentence triggers and local processing.
"""
"""If request should only be handled by agent, ignoring sentence triggers and local processing."""
_streamed_response_text = False
"""If the conversation agent streamed response text to TTS result."""
@@ -935,7 +932,6 @@ class PipelineRun:
{
"engine": engine,
"metadata": asdict(metadata),
"audio_processing": asdict(self.stt_provider.audio_processing),
},
)
)
@@ -1049,11 +1045,7 @@ class PipelineRun:
if agent_info is None:
raise IntentRecognitionError(
code="intent-agent-not-found",
message=(
f"Intent recognition engine"
f" {self._conversation_data.continue_conversation_agent}"
" asked for follow-up but is no longer found"
),
message=f"Intent recognition engine {self._conversation_data.continue_conversation_agent} asked for follow-up but is no longer found",
)
self._intent_agent_only = True
@@ -1157,17 +1149,14 @@ class PipelineRun:
nonlocal delta_character_count
# Streamed responses are not cached. That's why we
# only start streaming text after we have received
# enough characters that indicates it will be a long
# response or if we have received text, and then a
# tool call.
# Streamed responses are not cached. That's why we only start streaming text after
# we have received enough characters that indicates it will be a long response
# or if we have received text, and then a tool call.
# Tool call after we already received text
start_streaming = delta_character_count > 0 and delta.get("tool_calls")
# Count characters in the content and test if we
# exceed streaming threshold
# Count characters in the content and test if we exceed streaming threshold
if not start_streaming and content:
delta_character_count += len(content)
start_streaming = delta_character_count > STREAM_RESPONSE_CHARS
@@ -1197,8 +1186,7 @@ class PipelineRun:
parts.append(tts_input_stream.get_nowait())
tts_input_stream.put_nowait(
"".join(
# At this point parts is only strings,
# None indicates end of queue
# At this point parts is only strings, None indicates end of queue
cast(list[str], parts)
)
)
@@ -1439,8 +1427,7 @@ class PipelineRun:
code="tts-not-supported",
message=(
f"Text-to-speech engine {engine} "
f"does not support language {self.pipeline.tts_language}"
f" or options {tts_options}:"
f"does not support language {self.pipeline.tts_language} or options {tts_options}:"
f" {err}"
),
) from err
@@ -1554,10 +1541,7 @@ class PipelineRun:
async def process_volume_only(
self, audio_stream: AsyncIterable[bytes]
) -> AsyncGenerator[EnhancedAudioChunk]:
"""Apply volume transformation only with optional chunking.
No VAD/audio enhancements are applied.
"""
"""Apply volume transformation only (no VAD/audio enhancements) with optional chunking."""
timestamp_ms = 0
async for chunk in audio_stream:
if self.audio_settings.volume_multiplier != 1.0:
@@ -1576,11 +1560,7 @@ class PipelineRun:
async def process_enhance_audio(
self, audio_stream: AsyncIterable[bytes]
) -> AsyncGenerator[EnhancedAudioChunk]:
"""Split audio into chunks and apply audio enhancements.
Applies VAD/noise suppression/auto gain/volume
transformation.
"""
"""Split audio into chunks and apply VAD/noise suppression/auto gain/volume transformation."""
assert self.audio_enhancer is not None
timestamp_ms = 0
@@ -1683,7 +1663,7 @@ class PipelineInput:
"""Identifier of the device that is processing the input/output of the pipeline."""
satellite_id: str | None = None
"""Identifier of the satellite processing the pipeline."""
"""Identifier of the satellite that is processing the input/output of the pipeline."""
async def execute(self, validate: bool = False) -> None:
"""Run pipeline."""
@@ -1745,8 +1725,7 @@ class PipelineInput:
sec_since_last_wake_up = time.monotonic() - last_wake_up
if sec_since_last_wake_up < WAKE_WORD_COOLDOWN:
_LOGGER.debug(
"Speech-to-text cancelled to avoid"
" duplicate wake-up for %s",
"Speech-to-text cancelled to avoid duplicate wake-up for %s",
self.wake_word_phrase,
)
raise DuplicateWakeUpDetectedError(self.wake_word_phrase)
@@ -1759,8 +1738,7 @@ class PipelineInput:
stt_input_stream = stt_processed_stream
if stt_audio_buffer:
# Send audio in the buffer first to speech-to-text,
# then move on to stt_stream.
# Send audio in the buffer first to speech-to-text, then move on to stt_stream.
# This is basically an async itertools.chain.
async def buffer_then_audio_stream() -> AsyncGenerator[
EnhancedAudioChunk
@@ -2064,9 +2042,7 @@ class PipelineStorageCollectionWebsocket(
msg["id"],
{
"pipelines": async_get_pipelines(hass),
"preferred_pipeline": (
self.storage_collection.async_get_preferred_item()
),
"preferred_pipeline": self.storage_collection.async_get_preferred_item(),
},
)
@@ -5,7 +5,8 @@ from typing import cast
import voluptuous as vol
from homeassistant.components.assist_satellite import DOMAIN as ASSIST_SATELLITE_DOMAIN
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.components.repairs import RepairsFlow
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import entity_registry as er
REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name")
@@ -20,14 +21,14 @@ class AssistInProgressDeprecatedRepairFlow(RepairsFlow):
raise ValueError("Missing data")
self._data = data
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
async def async_step_init(self, _: None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm_disable_entity()
async def async_step_confirm_disable_entity(
self,
user_input: dict[str, str] | None = None,
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entity_registry = er.async_get(self.hass)
@@ -204,8 +204,7 @@ class VoiceCommandSegmenter:
) -> bool:
"""Process an audio chunk using an external VAD.
A buffer is required if the VAD requires fixed-sized audio
chunks (usually the case).
A buffer is required if the VAD requires fixed-sized audio chunks (usually the case).
Returns False when voice command is finished.
"""
@@ -294,10 +293,7 @@ def chunk_samples(
bytes_per_chunk: int,
leftover_chunk_buffer: AudioBuffer,
) -> Iterable[bytes]:
"""Yield fixed-sized chunks from samples.
Keeps leftover bytes from previous call(s).
"""
"""Yield fixed-sized chunks from samples, keeping leftover bytes from previous call(s)."""
if (len(leftover_chunk_buffer) + len(samples)) < bytes_per_chunk:
# Extend leftover chunk, but not enough samples to complete it

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