Compare commits

..

9 Commits

Author SHA1 Message Date
Michael 53ad8aab6c we reached gold meanwhile 2026-05-18 14:00:19 +00:00
Michael bf7083a7cb Merge branch 'dev' into fritzbox/record-current-iqs 2026-05-18 15:50:58 +02:00
Michael 521408fca4 Merge branch 'dev' into fritzbox/record-current-iqs 2026-05-14 15:40:19 +02:00
mib1185 52b78649d4 set not applicable rules to exempt 2026-05-10 10:03:38 +00:00
mib1185 900ab99668 docs-examples is meanwhile fullfilled
https://github.com/home-assistant/home-assistant.io/pull/45282
2026-05-10 10:01:25 +00:00
mib1185 8465a8df76 set reconfiguration-flow to done as we've a reconfig flow 2026-05-09 18:35:11 +00:00
mib1185 b761080152 we reached silver on the IQS 2026-05-09 17:22:19 +00:00
mib1185 d5bbe4501b log-when-unavailable is full-filled 2026-05-09 17:20:39 +00:00
mib1185 cfa6b3b8db we achieved bronze on the IQS 2026-05-09 17:05:41 +00:00
853 changed files with 12871 additions and 30516 deletions
@@ -18,13 +18,6 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
4. Ensure any existing review comments have been addressed.
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
## Verification:
- After the review, run parallel subagents for each finding to double check it.
- Spawn up to a maximum of 10 parallel subagents at a time.
- Gather the results from the subagents and summarize them in the final review comments.
## IMPORTANT:
- Just review. DO NOT make any changes
- Be constructive and specific in your comments
+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
-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/**"
-26
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"
}
],
@@ -197,13 +184,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 +213,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
@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.05.0"
BASE_IMAGE_VERSION: "2026.04.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
@@ -1,31 +0,0 @@
name: Check requirements (changes detection)
# Stage 1 of the agentic Check requirements workflow.
# Just kicks off Stage 2 (`check-requirements-dispatcher.yml`) which starts the agentic workflow
# yamllint disable-line rule:truthy
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "requirements*.txt"
- "homeassistant/package_constraints.txt"
- "pyproject.toml"
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
changes:
name: Requirements files changed
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: Record PR number
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |-
echo "Requirements files changed in PR #${PR_NUMBER}"
@@ -1,73 +0,0 @@
name: Check requirements (dispatcher)
# Stage 2 of the agentic Check requirements workflow. Runs on completion of
# stage 1 (`check-requirements-changes.yml`) and dispatches stage 3
# (`check-requirements.lock.yml`)
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
# yamllint disable-line rule:truthy
on: # zizmor: ignore[dangerous-triggers]
# workflow_run is safe here: this workflow does not check out PR code or run
# any code from the triggering PR. It only resolves the PR number from the
# head SHA and dispatches `check-requirements.lock.yml` with that number as
# a sanitized string input. The PR code is analysed downstream in the
# agentic workflow (`check-requirements.lock.yml`)
workflow_run:
workflows: ["Check requirements (changes detection)"]
types: [completed]
permissions: {}
jobs:
dispatch:
name: Dispatch agentic requirements check
if: >
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write # For triggering the downstream workflow
pull-requests: read # For querying PRs by commit SHA
steps:
- name: Resolve PR number from head SHA and trigger agentic requirements check
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const headSha = context.payload.workflow_run.head_sha;
const headBranch = context.payload.workflow_run.head_branch;
const headRepository = context.payload.workflow_run.head_repository;
const headRepo = headRepository.full_name;
// Query the head repository (which may be a fork). When the PR comes
// from a fork, the upstream's listPullRequestsAssociatedWithCommit
// returns no results for the fork's commit SHA.
const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: headRepository.owner.login,
repo: headRepository.name,
commit_sha: headSha,
});
const matches = pulls.filter(p =>
p.state === 'open'
&& p.head.ref === headBranch
&& p.head.repo?.full_name === headRepo
);
if (matches.length === 0) {
core.info(`No open PR found for head SHA ${headSha} on ${headRepo}:${headBranch}; nothing to dispatch.`);
return;
}
const defaultBranch = context.payload.workflow_run.repository.default_branch;
for (const pr of matches) {
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'check-requirements.lock.yml',
ref: defaultBranch,
inputs: {
pull_request_number: String(pr.number),
},
});
core.info(`Dispatched check-requirements.lock.yml for PR #${pr.number}.`);
}
File diff suppressed because it is too large Load Diff
-416
View File
@@ -1,416 +0,0 @@
---
on:
workflow_dispatch:
inputs:
pull_request_number:
description: "Pull request number to (re-)check"
required: true
type: number
permissions:
contents: read
pull-requests: read
issues: read
network:
allowed:
- python
tools:
web-fetch: {}
github:
toolsets: [default]
min-integrity: unapproved
safe-outputs:
add-comment:
max: 1
target: ${{ inputs.pull_request_number }}
concurrency:
group: ${{ github.workflow }}-${{ inputs.pull_request_number }}
cancel-in-progress: true
post-steps:
- name: Verify agent produced an add_comment safe-output
if: always()
run: |
OUTPUT=/tmp/gh-aw/agent_output.json
if [ ! -f "${OUTPUT}" ]; then
echo "::error::Agent output file ${OUTPUT} is missing; the agent did not run to completion."
exit 1
fi
if ! grep -q '"add_comment"' "${OUTPUT}"; then
echo "::error::Agent did not emit an add_comment safe-output; no review comment was posted to the PR."
echo "Agent output:"
cat "${OUTPUT}"
exit 1
fi
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.
---
# Check requirements
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
This workflow is triggered via `workflow_dispatch`. The PR number to check is
**#${{ inputs.pull_request_number }}**. Use that PR number for **every** GitHub
API call in the steps below (fetching the diff, the PR body, etc.). Do **not**
rely on `github.event.pull_request` — it is not populated for
`workflow_dispatch` runs.
Use the GitHub tool to fetch the PR diff for that PR number. 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 for PR
#${{ inputs.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 matching the bump
For **version bumps**: the PR description must contain a link to a changelog,
release notes page, or a diff/comparison URL that references the **exact
versions** being bumped (old → new) as recorded in the diff from Step 1.
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 (X) and the
new version (Y) 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. Confirm the link's version range matches the actual bump in the diff. If
the link references versions different from X → Y (for example, the PR
bumps `1.2.3 → 1.3.0` but the link points to `compare/v1.2.0...v1.2.4`),
the link does not match the bump.
Outcome:
- ✅ — a URL pointing to the correct repo with version references that match
the exact bump (X → Y).
- ❌ — no changelog/diff link is found, or the link does not match the actual
bump (X → Y). Explain what was found and what is expected.
## 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
(treat this as a static long-lived API token rather than OIDC).
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined.
If a static secret token is the only credential, mark ⚠️ for version
bumps (the package was already accepted at a previous version; suggest
the upstream maintainer switch to OIDC / Trusted Publisher for better
security) and ❌ for new packages.
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 is used, ⚠️ if the method cannot be determined. If a
protected static token is the only credential, mark ⚠️ for version bumps
(suggest the upstream maintainer switch to OIDC / Trusted Publisher for
better security) and ❌ for new packages.
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 -->
## Check requirements
| Package | Type | Old→New | License | Repo Public | CI Upload | Release Pipeline | PR Link |
|---------|------|---------|---------|-------------|-----------|------------------|---------|
| 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, and the PR link found (or missing, or mismatched with the
actual bump). 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).
</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
</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 invoked exclusively via `workflow_dispatch`. The stage-1
workflow `Check requirements (changes detection)` runs on `pull_request` with
a paths filter on the tracked requirements files, and its completion triggers
the dispatcher (`Check requirements (dispatcher)`) which calls this workflow
with the PR number. Members can also dispatch this workflow manually 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.
@@ -236,7 +236,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4o
system-prompt: |
@@ -62,7 +62,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4o-mini
system-prompt: |
+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:
+1 -1
View File
@@ -1 +1 @@
3.14.5
3.14.4
-1
View File
@@ -1,6 +1,5 @@
ignore: |
tests/fixtures/core/config/yaml_errors/
.github/workflows/*.lock.yml
rules:
braces:
level: error
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/
-1
View File
@@ -19,7 +19,6 @@ from .hub import AdsHub
DEFAULT_NAME = "ADS select"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
@@ -2,5 +2,4 @@
DOMAIN = "altruist"
# pylint: disable-next=home-assistant-duplicate-const
CONF_HOST = "host"
@@ -16,8 +16,6 @@ from .entity import AnthropicBaseLLMEntity
if TYPE_CHECKING:
from . import AnthropicConfigEntry
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
@@ -24,10 +24,10 @@ from homeassistant.const import (
CONF_API_KEY,
CONF_LLM_HASS_API,
CONF_NAME,
CONF_PROMPT,
)
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,
@@ -44,13 +44,12 @@ from .const import (
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_PROMPT_CACHING,
CONF_RECOMMENDED,
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,
}
)
+1 -4
View File
@@ -10,6 +10,7 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation"
DEFAULT_AI_TASK_NAME = "Claude AI Task"
CONF_RECOMMENDED = "recommended"
CONF_PROMPT = "prompt"
CONF_CHAT_MODEL = "chat_model"
CONF_CODE_EXECUTION = "code_execution"
CONF_MAX_TOKENS = "max_tokens"
@@ -17,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"
@@ -46,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,
@@ -4,16 +4,14 @@ from typing import Literal
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AnthropicConfigEntry
from .const import DOMAIN
from .const import CONF_PROMPT, DOMAIN
from .entity import AnthropicBaseLLMEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
@@ -5,10 +5,11 @@ from typing import TYPE_CHECKING, Any
from anthropic import __title__, __version__
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY, CONF_PROMPT
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_PROMPT,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_REGION,
+17 -55
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",
@@ -621,7 +609,6 @@ class AnthropicDeltaStream:
if isinstance(
content_block,
(
WebFetchToolResultBlock,
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
@@ -737,15 +724,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 +907,6 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"GetLiveContext",
"code_execution",
"web_search",
"web_fetch",
]
system = chat_log.content[0]
@@ -996,12 +980,12 @@ 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 +1023,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:
@@ -38,7 +38,10 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
parallel-updates:
status: exempt
comment: |
The API does not limit parallel updates.
reauthentication-flow: done
test-coverage: done
# Gold
@@ -40,11 +40,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] | None
) -> RepairsFlowResult:
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
"""Handle the steps of a fix flow."""
if user_input and user_input.get(CONF_CHAT_MODEL):
if user_input.get(CONF_CHAT_MODEL):
self._async_update_current_subentry(user_input)
target = await self._async_next_target()
@@ -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"
},
@@ -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):
@@ -16,9 +16,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ArcamFmjConfigEntry
from .entity import ArcamFmjEntity
# Read-only, coordinator-driven entities; no per-entity I/O to bound.
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
@@ -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,19 +18,15 @@ 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, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from .entity import ArcamFmjEntity, convert_exception
from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
# arcam-fmj serializes commands on a single TCP writer at the library
# layer; serialize at HA's layer to match the device's contract.
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
@@ -43,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(
translation_domain=DOMAIN, translation_key="connection_failed"
) from exception
return _convert_exception
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
"""Representation of a media device."""
@@ -64,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:
@@ -170,7 +179,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",
@@ -22,9 +22,6 @@ from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
# Read-only, coordinator-driven entities; no per-entity I/O to bound.
PARALLEL_UPDATES = 0
def _enum_options(value: type[IntOrTypeEnum]) -> list[str]:
return [
@@ -19,8 +19,6 @@ DEVICES = "devices"
MANUFACTURER = "ABB"
ATTR_DEVICE_NAME = "device_name"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_DEVICE_ID = "device_id"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
ATTR_FIRMWARE = "firmware"
+4 -22
View File
@@ -32,7 +32,6 @@ from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
BREAKS_IN_HA_VERSION = "2026.12.0"
AVEA_MAX_BRIGHTNESS = 4095
def _normalize_name(name: str | None) -> str | None:
@@ -42,16 +41,6 @@ def _normalize_name(name: str | None) -> str | None:
return name
def _ha_brightness_to_avea(brightness: int) -> int:
"""Convert Home Assistant brightness to Avea brightness."""
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
def _avea_brightness_to_ha(brightness: int) -> int:
"""Convert Avea brightness to Home Assistant brightness."""
return round(255 * (brightness / AVEA_MAX_BRIGHTNESS))
def _create_deprecated_yaml_issue(hass: HomeAssistant) -> None:
"""Create the deprecated YAML issue for Avea."""
ir.async_create_issue(
@@ -187,18 +176,15 @@ class AveaLight(LightEntity):
self._light = light
self._attr_name = entry_title
self._attr_brightness = light.brightness
self._last_brightness = 255
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if not kwargs:
self._light.set_brightness(_ha_brightness_to_avea(self._last_brightness))
self._light.set_brightness(4095)
else:
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS]
if brightness:
self._last_brightness = brightness
self._light.set_brightness(_ha_brightness_to_avea(brightness))
bright = round((kwargs[ATTR_BRIGHTNESS] / 255) * 4095)
self._light.set_brightness(bright)
if ATTR_HS_COLOR in kwargs:
rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
self._light.set_rgb(rgb[0], rgb[1], rgb[2])
@@ -206,8 +192,6 @@ class AveaLight(LightEntity):
def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
self._light.set_brightness(0)
self._attr_is_on = False
self._attr_brightness = 0
def update(self) -> None:
"""Fetch new state data for this light."""
@@ -222,7 +206,5 @@ class AveaLight(LightEntity):
if brightness is not None:
self._attr_is_on = brightness != 0
self._attr_brightness = _avea_brightness_to_ha(brightness)
if self._attr_brightness:
self._last_brightness = self._attr_brightness
self._attr_brightness = round(255 * (brightness / 4095))
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb_color)
+1 -1
View File
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["avea"],
"requirements": ["avea==1.8.0"]
"requirements": ["avea==1.6.1"]
}
-1
View File
@@ -11,7 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
# pylint: disable-next=home-assistant-duplicate-const
CONF_PREFIX = "prefix"
AWS_DOMAIN = "amazonaws.com"
@@ -20,12 +20,12 @@ from homeassistant.components.backup import (
OnProgressCallback,
suggested_filename,
)
from homeassistant.const import CONF_PREFIX
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.async_iterator import AsyncIteratorReader
from . import BackblazeConfigEntry
from .const import (
CONF_PREFIX,
DATA_BACKUP_AGENT_LISTENERS,
DOMAIN,
METADATA_FILE_SUFFIX,
@@ -8,7 +8,6 @@ from b2sdk.v2 import exception
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PREFIX
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
@@ -23,6 +22,7 @@ from .const import (
CONF_APPLICATION_KEY,
CONF_BUCKET,
CONF_KEY_ID,
CONF_PREFIX,
DOMAIN,
)
@@ -10,6 +10,7 @@ DOMAIN: Final = "backblaze_b2"
CONF_KEY_ID = "key_id"
CONF_APPLICATION_KEY = "application_key"
CONF_BUCKET = "bucket"
CONF_PREFIX = "prefix"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
@@ -16,7 +16,6 @@ CONF_DETAILS = "details"
CONF_PASSIVE = "passive"
# pylint: disable-next=home-assistant-duplicate-const
CONF_SOURCE: Final = "source"
CONF_SOURCE_DOMAIN: Final = "source_domain"
CONF_SOURCE_MODEL: Final = "source_model"
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==5.0.0",
"dbus-fast==4.0.4",
"habluetooth==6.1.0"
]
}
@@ -6,7 +6,6 @@ from typing import Final
ATTR_CID: Final = "cid"
ATTR_MAC: Final = "macAddr"
ATTR_MANUFACTURER: Final = "Sony"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL: Final = "model"
CONF_NICKNAME: Final = "nickname"
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==6.0.1"],
"requirements": ["python-bsblan==5.2.1"],
"zeroconf": [
{
"name": "bsb-lan*",
+1 -1
View File
@@ -8,7 +8,6 @@ from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -21,6 +20,7 @@ if TYPE_CHECKING:
LOGGER = logging.getLogger(__name__)
ATTR_DEVICE_ID = "device_id"
ATTR_MONDAY_SLOTS = "monday_slots"
ATTR_TUESDAY_SLOTS = "tuesday_slots"
ATTR_WEDNESDAY_SLOTS = "wednesday_slots"
+1 -3
View File
@@ -17,8 +17,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import TIMEOUT
type CalDavConfigEntry = ConfigEntry[caldav.DAVClient]
_LOGGER = logging.getLogger(__name__)
@@ -34,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CalDavConfigEntry) -> bo
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
ssl_verify_cert=entry.data[CONF_VERIFY_SSL],
timeout=TIMEOUT,
timeout=30,
)
try:
await hass.async_add_executor_job(client.principal)
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, TIMEOUT
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -65,7 +65,6 @@ class CalDavConfigFlow(ConfigFlow, domain=DOMAIN):
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
ssl_verify_cert=user_input[CONF_VERIFY_SSL],
timeout=TIMEOUT,
)
try:
await self.hass.async_add_executor_job(client.principal)
@@ -76,9 +75,6 @@ class CalDavConfigFlow(ConfigFlow, domain=DOMAIN):
# AuthorizationError can be raised if the url is incorrect or
# on some other unexpected server response.
return "cannot_connect"
except requests.Timeout as err:
_LOGGER.warning("Timeout connecting to CalDAV server: %s", err)
return "cannot_connect"
except requests.ConnectionError as err:
_LOGGER.warning("Connection Error connecting to CalDAV server: %s", err)
return "cannot_connect"
-1
View File
@@ -3,4 +3,3 @@
from typing import Final
DOMAIN: Final = "caldav"
TIMEOUT: Final = 30
@@ -13,7 +13,6 @@ if TYPE_CHECKING:
DOMAIN = "calendar"
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
# pylint: disable-next=home-assistant-duplicate-const
CONF_EVENT = "event"
@@ -7,7 +7,6 @@ from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
PLATFORMS: list[Platform] = [
@@ -25,9 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
if not ble_device:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"address": address},
f"Could not find Casper Glow device with address {address}"
)
glow = CasperGlow(ble_device)
@@ -54,9 +54,6 @@
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the Casper Glow: {error}"
},
"device_not_found": {
"message": "Could not find Casper Glow device with address {address}"
}
}
}
+73 -40
View File
@@ -8,7 +8,7 @@ from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_UUID
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 SelectSelector, SelectSelectorConfig
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -17,7 +17,7 @@ from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
if TYPE_CHECKING:
from . import CastConfigEntry
CONF_MORE_OPTIONS = "more_options"
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
KNOWN_HOSTS_SCHEMA = vol.Schema(
{
vol.Optional(
@@ -27,19 +27,7 @@ KNOWN_HOSTS_SCHEMA = vol.Schema(
)
}
)
OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
{
vol.Required(CONF_MORE_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_UUID): str,
vol.Optional(CONF_IGNORE_CEC): str,
}
),
SectionConfig(collapsed=True),
)
}
)
WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
class FlowHandler(ConfigFlow, domain=DOMAIN):
@@ -104,55 +92,100 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
class CastOptionsFlowHandler(OptionsFlow):
"""Handle Google Cast options."""
async def async_step_init(
def __init__(self) -> None:
"""Initialize Google Cast options flow."""
self.updated_config: dict[str, Any] = {}
async def async_step_init(self, user_input: None = None) -> ConfigFlowResult:
"""Manage the Google Cast options."""
return await self.async_step_basic_options()
async def async_step_basic_options(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
errors: dict[str, str] = {}
if user_input is not None:
ignore_cec = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
)
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
wanted_uuid = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
)
updated_config = dict(self.config_entry.data)
updated_config[CONF_IGNORE_CEC] = ignore_cec
updated_config[CONF_KNOWN_HOSTS] = known_hosts
updated_config[CONF_UUID] = wanted_uuid
self.updated_config = dict(self.config_entry.data)
self.updated_config[CONF_KNOWN_HOSTS] = known_hosts
if self.show_advanced_options:
return await self.async_step_advanced_options()
self.hass.config_entries.async_update_entry(
self.config_entry, data=updated_config
self.config_entry, data=self.updated_config
)
return self.async_create_entry(title="", data={})
suggested: dict[str, Any] = {CONF_MORE_OPTIONS: {}}
if CONF_KNOWN_HOSTS in self.config_entry.data:
suggested[CONF_KNOWN_HOSTS] = self.config_entry.data[CONF_KNOWN_HOSTS]
for key in (CONF_UUID, CONF_IGNORE_CEC):
if key not in self.config_entry.data:
continue
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
self.config_entry.data[key]
return self.async_show_form(
step_id="basic_options",
data_schema=self.add_suggested_values_to_schema(
KNOWN_HOSTS_SCHEMA, self.config_entry.data
),
errors=errors,
last_step=not self.show_advanced_options,
)
async def async_step_advanced_options(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
errors: dict[str, str] = {}
if user_input is not None:
bad_cec, ignore_cec = _string_to_list(
user_input.get(CONF_IGNORE_CEC, ""), IGNORE_CEC_SCHEMA
)
bad_uuid, wanted_uuid = _string_to_list(
user_input.get(CONF_UUID, ""), WANTED_UUID_SCHEMA
)
if not bad_cec and not bad_uuid:
self.updated_config[CONF_IGNORE_CEC] = ignore_cec
self.updated_config[CONF_UUID] = wanted_uuid
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.updated_config
)
return self.async_create_entry(title="", data={})
fields: dict[vol.Marker, type[str]] = {}
current_config = self.config_entry.data
suggested_value = _list_to_string(current_config.get(CONF_UUID))
_add_with_suggestion(fields, CONF_UUID, suggested_value)
suggested_value = _list_to_string(current_config.get(CONF_IGNORE_CEC))
_add_with_suggestion(fields, CONF_IGNORE_CEC, suggested_value)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, suggested),
step_id="advanced_options",
data_schema=vol.Schema(fields),
errors=errors,
last_step=True,
)
def _list_to_string(items: list[str]) -> str:
def _list_to_string(items):
comma_separated_string = ""
if items:
comma_separated_string = ",".join(items)
return comma_separated_string
def _string_to_list(string: str) -> list[str]:
return [x.strip() for x in string.split(",") if x.strip()]
def _string_to_list(string, schema):
invalid = False
items = [x.strip() for x in string.split(",") if x.strip()]
try:
items = schema(items)
except vol.Invalid:
invalid = True
return invalid, items
def _trim_items(items: list[str]) -> list[str]:
return [x.strip() for x in items if x.strip()]
def _add_with_suggestion(
fields: dict[vol.Marker, type[str]], key: str, suggested_value: str
) -> None:
fields[vol.Optional(key, description={"suggested_value": suggested_value})] = str
+12 -14
View File
@@ -24,27 +24,25 @@
}
},
"options": {
"error": {
"invalid_known_hosts": "[%key:component::cast::config::error::invalid_known_hosts%]"
},
"step": {
"init": {
"advanced_options": {
"data": {
"ignore_cec": "Ignore CEC",
"uuid": "Allowed UUIDs"
},
"description": "Allowed UUIDs - A comma-separated list of UUIDs of Cast devices to add to Home Assistant. Use only if you dont want to add all available cast devices.\nIgnore CEC - A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be passed to pychromecast.IGNORE_CEC.",
"title": "Advanced Google Cast configuration"
},
"basic_options": {
"data": {
"known_hosts": "[%key:component::cast::config::step::config::data::known_hosts%]"
},
"data_description": {
"known_hosts": "[%key:component::cast::config::step::config::data_description::known_hosts%]"
},
"sections": {
"more_options": {
"data": {
"ignore_cec": "Ignore CEC",
"uuid": "Allowed UUIDs"
},
"data_description": {
"ignore_cec": "A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be passed to pychromecast.IGNORE_CEC.",
"uuid": "A comma-separated list of UUIDs of Cast devices to add to Home Assistant. Use only if you dont want to add all available cast devices."
},
"name": "More options"
}
},
"title": "[%key:component::cast::config::step::config::title%]"
}
}
@@ -95,42 +95,3 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=self._errors,
)
async def async_step_reconfigure(
self,
user_input: Mapping[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle reconfiguration of an existing entry."""
self._errors = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
host = user_input[CONF_HOST]
port = user_input.get(CONF_PORT, DEFAULT_PORT)
if (
host != reconfigure_entry.data[CONF_HOST]
or port != reconfigure_entry.data[CONF_PORT]
):
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
if await self._test_connection(user_input):
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_HOST: host, CONF_PORT: port},
unique_id=f"{host}:{port}",
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
),
user_input or reconfigure_entry.data,
),
errors=self._errors,
)
@@ -2,8 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"import_failed": "Import from config failed",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"import_failed": "Import from config failed"
},
"error": {
"connection_refused": "Connection refused when connecting to host",
@@ -12,13 +11,6 @@
"resolve_failed": "This host cannot be resolved"
},
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"title": "Reconfigure the certificate to test"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
+2 -2
View File
@@ -17,8 +17,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
APPLICATION_NAME,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
@@ -47,6 +45,8 @@ HA_USER_AGENT = (
)
ATTR_UID = "uid"
ATTR_LATITUDE = "latitude"
ATTR_LONGITUDE = "longitude"
ATTR_EMPTY_SLOTS = "empty_slots"
ATTR_FREE_EBIKES = "free_ebikes"
ATTR_TIMESTAMP = "timestamp"
@@ -29,7 +29,6 @@ BASE_API_URL = "https://rest.clicksend.com/v3"
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
# pylint: disable-next=home-assistant-duplicate-const
CONF_LANGUAGE = "language"
CONF_VOICE = "voice"
+22 -22
View File
@@ -32,28 +32,28 @@ set_temperature:
max: 250
step: 0.1
mode: box
temperature_range:
fields:
target_temp_high:
filter:
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
selector:
number:
min: 0
max: 250
step: 0.1
mode: box
target_temp_low:
filter:
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
selector:
number:
min: 0
max: 250
step: 0.1
mode: box
target_temp_high:
filter:
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
advanced: true
selector:
number:
min: 0
max: 250
step: 0.1
mode: box
target_temp_low:
filter:
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
advanced: true
selector:
number:
min: 0
max: 250
step: 0.1
mode: box
hvac_mode:
selector:
state:
@@ -373,12 +373,7 @@
"name": "Target temperature"
}
},
"name": "Set thermostat target temperature",
"sections": {
"temperature_range": {
"name": "Temperature range"
}
}
"name": "Set thermostat target temperature"
},
"toggle": {
"description": "Toggles a thermostat on/off.",
@@ -11,7 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
# pylint: disable-next=home-assistant-duplicate-const
CONF_PREFIX = "prefix"
# R2 is S3-compatible. Endpoint should be like:
@@ -6,5 +6,4 @@ ATTR_URL = "color_extract_url"
DOMAIN = "color_extractor"
DEFAULT_NAME = "Color extractor"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_TURN_ON = "turn_on"
@@ -10,11 +10,10 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.process import kill_subprocess
from .const import CONF_COMMAND_TIMEOUT, DOMAIN, LOGGER
from .const import CONF_COMMAND_TIMEOUT, LOGGER
from .utils import create_platform_yaml_not_supported_issue, render_template_args
_LOGGER = logging.getLogger(__name__)
@@ -67,18 +66,9 @@ class CommandLineNotificationService(BaseNotificationService):
proc.returncode,
command,
)
except subprocess.TimeoutExpired as err:
_LOGGER.debug("Timeout for command: %s", command)
# pylint: disable-next=home-assistant-action-swallowed-exception
except subprocess.TimeoutExpired:
_LOGGER.error("Timeout for command: %s", command)
kill_subprocess(proc)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_error",
translation_placeholders={"command": command},
) from err
except subprocess.SubprocessError as err:
_LOGGER.debug("Error trying to exec command: %s", command)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error",
translation_placeholders={"command": command, "error": str(err)},
) from err
except subprocess.SubprocessError:
_LOGGER.error("Error trying to exec command: %s", command)
@@ -1,10 +1,8 @@
{
"exceptions": {
"command_error": {
"message": "Error trying to execute command: {command}. Error: {error}"
},
"timeout_error": {
"message": "Timeout trying to execute command: {command}"
"issues": {
"platform_yaml_not_supported": {
"description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.",
"title": "Platform YAML is not supported in Command Line"
}
},
"services": {
@@ -4,9 +4,7 @@ import asyncio
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.entity_platform import (
async_create_platform_config_not_supported_issue,
)
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from .const import DOMAIN, LOGGER
@@ -100,11 +98,13 @@ def create_platform_yaml_not_supported_issue(
hass: HomeAssistant, platform_domain: str
) -> None:
"""Create an issue when platform yaml is used."""
async_create_platform_config_not_supported_issue(
async_create_issue(
hass,
DOMAIN,
platform_domain,
yaml_config_under_integration_supported=True,
f"{platform_domain}_platform_yaml_not_supported",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="platform_yaml_not_supported",
translation_placeholders={"platform": platform_domain},
learn_more_url="https://www.home-assistant.io/integrations/command_line/",
logger=LOGGER,
)
@@ -91,11 +91,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Compensation sensor."""
hass.data[DATA_COMPENSATION] = {}
# Exit early if no compensations are configured using the compensation: key in configuration.yaml.
# This allows us to create an issue if platform: compensation is present in the sensor: section.
if DOMAIN not in config:
return True
for compensation, conf in config[DOMAIN].items():
_LOGGER.debug("Setup %s.%s", DOMAIN, compensation)
@@ -8,7 +8,6 @@ import numpy as np
from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
CONF_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorEntity,
)
from homeassistant.const import (
@@ -32,10 +31,7 @@ from homeassistant.core import (
State,
callback,
)
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_create_platform_config_not_supported_issue,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -45,7 +41,6 @@ from .const import (
CONF_PRECISION,
DATA_COMPENSATION,
DEFAULT_NAME,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
@@ -63,14 +58,6 @@ async def async_setup_platform(
) -> None:
"""Set up the Compensation sensor."""
if discovery_info is None:
async_create_platform_config_not_supported_issue(
hass,
DOMAIN,
SENSOR_DOMAIN,
yaml_config_under_integration_supported=True,
learn_more_url="https://www.home-assistant.io/integrations/compensation/",
logger=_LOGGER,
)
return
compensation: str = discovery_info[CONF_COMPENSATION]
@@ -16,7 +16,6 @@ PLATFORMS = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
]
@@ -279,20 +279,6 @@
"no_alarm": "mdi:check-circle"
}
}
},
"switch": {
"device_on_off": {
"default": "mdi:power",
"state": {
"off": "mdi:power-off"
}
},
"force_dhw": {
"default": "mdi:water-boiler",
"state": {
"off": "mdi:water-boiler-off"
}
}
}
}
}
@@ -421,14 +421,6 @@
"weather_curve": {
"name": "Weather curve"
}
},
"switch": {
"device_on_off": {
"name": "Device on/off"
},
"force_dhw": {
"name": "Force domestic hot water"
}
}
}
}
-132
View File
@@ -1,132 +0,0 @@
"""Switch platform for Compit integration."""
from dataclasses import dataclass
from typing import Any
from compit_inext_api.consts import CompitParameter
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER_NAME
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class CompitDeviceDescription:
"""Class to describe a Compit device."""
name: str
"""Name of the device."""
parameters: list[SwitchEntityDescription]
"""Parameters of the device."""
DESCRIPTIONS: dict[CompitParameter, SwitchEntityDescription] = {
CompitParameter.DEVICE_ON_OFF: SwitchEntityDescription(
key=CompitParameter.DEVICE_ON_OFF.value,
translation_key="device_on_off",
),
CompitParameter.FORCE_DHW: SwitchEntityDescription(
key=CompitParameter.FORCE_DHW.value,
translation_key="force_dhw",
),
}
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
210: CompitDeviceDescription(
name="EL750",
parameters=[DESCRIPTIONS[CompitParameter.DEVICE_ON_OFF]],
),
224: CompitDeviceDescription(
name="R 900",
parameters=[
DESCRIPTIONS[CompitParameter.FORCE_DHW],
],
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: CompitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Compit switch entities from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
CompitSwitch(
coordinator,
device_id,
device_definition.name,
entity_description,
)
for device_id, device in coordinator.connector.all_devices.items()
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
for entity_description in device_definition.parameters
)
class CompitSwitch(CoordinatorEntity[CompitDataUpdateCoordinator], SwitchEntity):
"""Representation of a Compit switch entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: CompitDataUpdateCoordinator,
device_id: int,
device_name: str,
entity_description: SwitchEntityDescription,
) -> None:
"""Initialize the switch entity."""
super().__init__(coordinator)
self.device_id = device_id
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(device_id))},
name=device_name,
manufacturer=MANUFACTURER_NAME,
model=device_name,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.connector.get_device(self.device_id) is not None
)
@property
def is_on(self) -> bool | None:
"""Return the state of the switch."""
value = self.coordinator.connector.get_current_option(
self.device_id, CompitParameter(self.entity_description.key)
)
return True if value == STATE_ON else False if value == STATE_OFF else None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.coordinator.connector.select_device_option(
self.device_id, CompitParameter(self.entity_description.key), STATE_ON
)
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.coordinator.connector.select_device_option(
self.device_id, CompitParameter(self.entity_description.key), STATE_OFF
)
self.async_write_ha_state()
@@ -19,7 +19,6 @@ ATTR_AGENT_ID = "agent_id"
ATTR_CONVERSATION_ID = "conversation_id"
SERVICE_PROCESS = "process"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_RELOAD = "reload"
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
@@ -10,7 +10,6 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
@@ -59,7 +58,7 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
if not self.coordinator.data.week_plan:
return None
today = dt_util.now().date()
today = date.today() # noqa: DTZ011
for day_data in self.coordinator.data.week_plan:
day_date = date.fromisoformat(day_data.id)
if day_date >= today and day_data.recipes:
@@ -1,7 +1,7 @@
"""DataUpdateCoordinator for the Cookidoo integration."""
from dataclasses import dataclass
from datetime import timedelta
from datetime import date, timedelta
import logging
from cookidoo_api import (
@@ -21,7 +21,6 @@ from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -82,9 +81,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
ingredient_items = await self.cookidoo.get_ingredient_items()
additional_items = await self.cookidoo.get_additional_items()
subscription = await self.cookidoo.get_active_subscription()
week_plan = await self.cookidoo.get_recipes_in_calendar_week(
dt_util.now().date()
)
week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today()) # noqa: DTZ011
except CookidooAuthException:
try:
await self.cookidoo.refresh_token()
@@ -1,20 +1,12 @@
"""The Data Grand Lyon integration."""
import asyncio
from data_grand_lyon_ha import DataGrandLyonClient
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .coordinator import (
DataGrandLyonConfigEntry,
DataGrandLyonData,
DataGrandLyonTclCoordinator,
DataGrandLyonVelovCoordinator,
)
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -30,16 +22,10 @@ async def async_setup_entry(
password=entry.data[CONF_PASSWORD],
)
tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client)
velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client)
coordinator = DataGrandLyonCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
coordinators: list[DataUpdateCoordinator] = [tcl_coordinator, velov_coordinator]
await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators))
entry.runtime_data = DataGrandLyonData(
tcl_coordinator=tcl_coordinator,
velov_coordinator=velov_coordinator,
)
entry.runtime_data = coordinator
entry.async_on_unload(entry.add_update_listener(async_update_entry))
@@ -31,12 +31,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon binary sensor entities."""
velov_coordinator = entry.runtime_data.velov_coordinator
coordinator = entry.runtime_data
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
async_add_entities(
(
DataGrandLyonVelovBinarySensor(velov_coordinator, subentry, description)
DataGrandLyonVelovBinarySensor(coordinator, subentry, description)
for description in VELOV_BINARY_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
@@ -50,5 +50,6 @@ class DataGrandLyonVelovBinarySensor(DataGrandLyonVelovEntity, BinarySensorEntit
def is_on(self) -> bool:
"""Return true if the station is open."""
return (
self.coordinator.data[self._subentry_id].status == VelovStationStatus.OPEN
self.coordinator.data.velov_stations[self._subentry_id].status
== VelovStationStatus.OPEN
)
@@ -28,20 +28,19 @@ from .const import (
SUBENTRY_TYPE_VELOV_STATION,
)
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator]
@dataclass
class DataGrandLyonData:
"""Runtime data for the Data Grand Lyon integration."""
class DataGrandLyonCoordinatorData:
"""Data returned by the coordinator."""
tcl_coordinator: DataGrandLyonTclCoordinator
velov_coordinator: DataGrandLyonVelovCoordinator
stops: dict[str, list[TclPassage]]
velov_stations: dict[str, VelovStation]
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData]
class DataGrandLyonTclCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]):
"""Coordinator for TCL transit passages."""
class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonCoordinatorData]):
"""Coordinator for the Data Grand Lyon integration."""
config_entry: DataGrandLyonConfigEntry
@@ -57,112 +56,82 @@ class DataGrandLyonTclCoordinator(DataUpdateCoordinator[dict[str, list[TclPassag
hass,
LOGGER,
config_entry=entry,
name=f"{DOMAIN}_tcl",
name=DOMAIN,
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> dict[str, list[TclPassage]]:
"""Fetch data for all monitored stops."""
async def _async_update_data(self) -> DataGrandLyonCoordinatorData:
"""Fetch data for all monitored stops and Vélo'v stations."""
stop_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP)
)
if not stop_subentries:
return {}
try:
all_passages = await self.client.get_tcl_passages()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_tcl",
) from err
except (ClientError, TimeoutError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_tcl",
) from err
lines_stops = [
(subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
for subentry in stop_subentries
]
grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops)
stops: dict[str, list[TclPassage]] = {}
for subentry in stop_subentries:
key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
sorted_passages = sort_tcl_passages_by_time(grouped[key])
if sorted_passages:
stops[subentry.subentry_id] = sorted_passages
else:
LOGGER.warning(
"No TCL passages found for subentry %s",
subentry.subentry_id,
)
return stops
class DataGrandLyonVelovCoordinator(DataUpdateCoordinator[dict[str, VelovStation]]):
"""Coordinator for Vélo'v stations."""
config_entry: DataGrandLyonConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: DataGrandLyonConfigEntry,
client: DataGrandLyonClient,
) -> None:
"""Initialize the coordinator."""
self.client = client
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=f"{DOMAIN}_velov",
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> dict[str, VelovStation]:
"""Fetch data for all monitored Vélo'v stations."""
velov_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION)
)
if not velov_subentries:
return {}
try:
all_stations = await self.client.get_velov_stations()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_velov",
) from err
except (ClientError, TimeoutError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_velov",
) from err
station_ids = [subentry.data[CONF_STATION_ID] for subentry in velov_subentries]
found = find_velov_stations_by_ids(all_stations, station_ids)
has_stops = bool(stop_subentries)
has_velov = bool(velov_subentries)
stops: dict[str, list[TclPassage]] = {}
velov_stations: dict[str, VelovStation] = {}
for subentry in velov_subentries:
station = found[subentry.data[CONF_STATION_ID]]
if station is not None:
velov_stations[subentry.subentry_id] = station
tcl_success = not has_stops
velov_success = not has_velov
if has_stops:
try:
all_passages = await self.client.get_tcl_passages()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
LOGGER.warning("Error fetching TCL passages: %s", err)
except (ClientError, TimeoutError) as err:
LOGGER.warning("Error fetching TCL passages: %s", err)
else:
LOGGER.warning(
"Vélo'v station not found for subentry %s",
subentry.subentry_id,
)
return velov_stations
tcl_success = True
lines_stops = [
(subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
for subentry in stop_subentries
]
grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops)
for subentry in stop_subentries:
key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
stops[subentry.subentry_id] = sort_tcl_passages_by_time(
grouped[key]
)
if has_velov:
try:
all_stations = await self.client.get_velov_stations()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
LOGGER.warning("Error fetching Vélo'v stations: %s", err)
except (ClientError, TimeoutError) as err:
LOGGER.warning("Error fetching Vélo'v stations: %s", err)
else:
velov_success = True
station_ids = [
subentry.data[CONF_STATION_ID] for subentry in velov_subentries
]
found = find_velov_stations_by_ids(all_stations, station_ids)
for subentry in velov_subentries:
station = found[subentry.data[CONF_STATION_ID]]
if station is not None:
velov_stations[subentry.subentry_id] = station
else:
LOGGER.warning(
"Vélo'v station not found for subentry %s",
subentry.subentry_id,
)
if not tcl_success and not velov_success:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_all",
)
return DataGrandLyonCoordinatorData(stops=stops, velov_stations=velov_stations)
@@ -16,16 +16,18 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
"coordinator_data": {
"stops": {
subentry_id: [asdict(passage) for passage in passages]
for subentry_id, passages in entry.runtime_data.tcl_coordinator.data.items()
for subentry_id, passages in coordinator.data.stops.items()
},
"velov_stations": {
subentry_id: asdict(station)
for subentry_id, station in entry.runtime_data.velov_coordinator.data.items()
for subentry_id, station in coordinator.data.velov_stations.items()
},
},
}
@@ -3,25 +3,20 @@
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator
from .coordinator import DataGrandLyonCoordinator
class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator](
CoordinatorEntity[_CoordinatorT]
):
class DataGrandLyonEntity(CoordinatorEntity[DataGrandLyonCoordinator]):
"""Base entity for Data Grand Lyon."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: _CoordinatorT,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: EntityDescription,
manufacturer: str,
@@ -42,33 +37,23 @@ class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator](
entry_type=DeviceEntryType.SERVICE,
)
@property
def available(self) -> bool:
"""Return True if subentry data is available."""
return super().available and self._subentry_id in self.coordinator.data
class DataGrandLyonTclEntity(DataGrandLyonEntity[DataGrandLyonTclCoordinator]):
"""Base entity for Data Grand Lyon TCL stops."""
def __init__(
self,
coordinator: DataGrandLyonTclCoordinator,
subentry: ConfigSubentry,
description: EntityDescription,
) -> None:
"""Initialize the TCL entity."""
super().__init__(coordinator, subentry, description, "TCL", "Stop")
class DataGrandLyonVelovEntity(DataGrandLyonEntity[DataGrandLyonVelovCoordinator]):
class DataGrandLyonVelovEntity(DataGrandLyonEntity):
"""Base entity for Data Grand Lyon Vélo'v stations."""
def __init__(
self,
coordinator: DataGrandLyonVelovCoordinator,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: EntityDescription,
) -> None:
"""Initialize the Vélo'v entity."""
super().__init__(coordinator, subentry, description, "JCDecaux", "Station")
@property
def available(self) -> bool:
"""Return True if the station data is available."""
return (
super().available
and self._subentry_id in self.coordinator.data.velov_stations
)
@@ -12,13 +12,14 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION
from .coordinator import DataGrandLyonConfigEntry
from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
from .entity import DataGrandLyonEntity, DataGrandLyonVelovEntity
PARALLEL_UPDATES = 0
@@ -169,13 +170,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon sensor entities."""
tcl_coordinator = entry.runtime_data.tcl_coordinator
velov_coordinator = entry.runtime_data.velov_coordinator
coordinator = entry.runtime_data
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
async_add_entities(
(
DataGrandLyonStopSensor(tcl_coordinator, subentry, description)
DataGrandLyonStopSensor(coordinator, subentry, description)
for description in STOP_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
@@ -184,31 +184,41 @@ async def async_setup_entry(
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
async_add_entities(
(
DataGrandLyonVelovSensor(velov_coordinator, subentry, description)
DataGrandLyonVelovSensor(coordinator, subentry, description)
for description in VELOV_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
)
class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity):
class DataGrandLyonStopSensor(DataGrandLyonEntity, SensorEntity):
"""Sensor for Data Grand Lyon stop departures."""
entity_description: DataGrandLyonStopSensorEntityDescription
@property
def available(self) -> bool:
"""Return True if the departure index exists."""
return super().available and self.entity_description.departure_index < len(
self.coordinator.data[self._subentry_id]
)
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: DataGrandLyonStopSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, subentry, description, "TCL", "Stop")
def _get_departure(self) -> TclPassage | None:
"""Return the departure for this sensor's index, or None."""
departures = self.coordinator.data.stops.get(self._subentry_id, [])
index = self.entity_description.departure_index
if index >= len(departures):
return None
return departures[index]
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
departure = self.coordinator.data[self._subentry_id][
self.entity_description.departure_index
]
departure = self._get_departure()
if departure is None:
return None
return self.entity_description.value_fn(departure)
@@ -217,9 +227,18 @@ class DataGrandLyonVelovSensor(DataGrandLyonVelovEntity, SensorEntity):
entity_description: DataGrandLyonVelovSensorEntityDescription
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: DataGrandLyonVelovSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, subentry, description)
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
return self.entity_description.value_fn(
self.coordinator.data[self._subentry_id]
self.coordinator.data.velov_stations[self._subentry_id]
)
@@ -158,11 +158,11 @@
"auth_failed": {
"message": "Authentication failed for Data Grand Lyon."
},
"update_failed_tcl": {
"message": "Error fetching TCL departures from Data Grand Lyon."
"update_failed_all": {
"message": "[%key:component::data_grand_lyon::exceptions::update_failed_all_stops::message%]"
},
"update_failed_velov": {
"message": "Error fetching Vélo'v stations from Data Grand Lyon."
"update_failed_all_stops": {
"message": "Error fetching Data Grand Lyon data: all requests failed."
}
}
}
-1
View File
@@ -43,7 +43,6 @@ PLATFORMS = [
]
ATTR_DARK = "dark"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCKED = "locked"
ATTR_OFFSET = "offset"
ATTR_ON = "on"
+20 -34
View File
@@ -12,9 +12,9 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
@@ -37,71 +37,49 @@ async def async_setup_entry(
async_add_entities(
[
DemoSensor(
"sensor_1",
"sensor_1",
"Outside Temperature",
15.6,
SensorDeviceClass.TEMPERATURE,
SensorStateClass.MEASUREMENT,
UnitOfTemperature.CELSIUS,
),
DemoSensor(
"battery_1",
"sensor_1",
"Outside Temperature",
12,
SensorDeviceClass.BATTERY,
SensorStateClass.MEASUREMENT,
PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery",
),
DemoSensor(
"sensor_2",
"sensor_2",
"Outside Humidity",
54,
SensorDeviceClass.HUMIDITY,
SensorStateClass.MEASUREMENT,
PERCENTAGE,
None,
),
DemoSensor(
"sensor_3",
"sensor_3",
"Carbon monoxide",
54,
SensorDeviceClass.CO,
SensorStateClass.MEASUREMENT,
CONCENTRATION_PARTS_PER_MILLION,
None,
),
DemoSensor(
"sensor_4",
"sensor_4",
"Carbon dioxide",
54,
SensorDeviceClass.CO2,
SensorStateClass.MEASUREMENT,
CONCENTRATION_PARTS_PER_MILLION,
14,
),
DemoSensor(
"battery_4",
"sensor_4",
"Carbon dioxide",
99,
SensorDeviceClass.BATTERY,
SensorStateClass.MEASUREMENT,
PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery",
),
DemoSensor(
"sensor_5",
"sensor_5",
"Power consumption",
100,
SensorDeviceClass.POWER,
SensorStateClass.MEASUREMENT,
UnitOfPower.WATT,
None,
),
DemoSumSensor(
"sensor_6",
@@ -110,6 +88,7 @@ async def async_setup_entry(
SensorDeviceClass.ENERGY,
SensorStateClass.TOTAL,
UnitOfEnergy.KILO_WATT_HOUR,
None,
"total_energy_kwh",
),
DemoSumSensor(
@@ -119,6 +98,7 @@ async def async_setup_entry(
SensorDeviceClass.ENERGY,
SensorStateClass.TOTAL,
UnitOfEnergy.MEGA_WATT_HOUR,
None,
"total_energy_mwh",
),
DemoSumSensor(
@@ -128,6 +108,7 @@ async def async_setup_entry(
SensorDeviceClass.GAS,
SensorStateClass.TOTAL,
UnitOfVolume.CUBIC_METERS,
None,
"total_gas_m3",
),
DemoSumSensor(
@@ -137,16 +118,17 @@ async def async_setup_entry(
SensorDeviceClass.GAS,
SensorStateClass.TOTAL,
UnitOfVolume.CUBIC_FEET,
None,
"total_gas_ft3",
),
DemoSensor(
unique_id="sensor_10",
device_id="sensor_10",
device_name="Thermostat",
state="eco",
device_class=SensorDeviceClass.ENUM,
state_class=None,
unit_of_measurement=None,
battery=None,
options=["away", "comfort", "eco", "sleep"],
translation_key="thermostat_mode",
),
@@ -158,21 +140,20 @@ class DemoSensor(SensorEntity):
"""Representation of a Demo sensor."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(
self,
unique_id: str,
device_id: str,
device_name: str | None,
state: float | str | None,
device_class: SensorDeviceClass,
state_class: SensorStateClass | None,
unit_of_measurement: str | None,
battery: int | None,
options: list[str] | None = None,
translation_key: str | None = None,
entity_category: EntityCategory | None = None,
entity_name: str | None = None,
) -> None:
"""Initialize the sensor."""
self._attr_device_class = device_class
@@ -182,14 +163,15 @@ class DemoSensor(SensorEntity):
self._attr_unique_id = unique_id
self._attr_options = options
self._attr_translation_key = translation_key
self._attr_entity_category = entity_category
self._attr_name = entity_name
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
if battery:
self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery}
class DemoSumSensor(RestoreSensor):
"""Representation of a Demo sensor."""
@@ -205,6 +187,7 @@ class DemoSumSensor(RestoreSensor):
device_class: SensorDeviceClass,
state_class: SensorStateClass | None,
unit_of_measurement: str | None,
battery: int | None,
suggested_entity_id: str,
) -> None:
"""Initialize the sensor."""
@@ -221,6 +204,9 @@ class DemoSumSensor(RestoreSensor):
name=device_name,
)
if battery:
self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery}
@callback
def _async_bump_sum(self, now: datetime) -> None:
"""Bump the sum."""
@@ -5,7 +5,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from .config_entry import ( # noqa: F401
BaseScannerEntity,
ScannerEntity,
ScannerEntityDescription,
TrackerEntity,
@@ -166,11 +166,7 @@ def _async_register_mac(
class BaseTrackerEntity(Entity):
"""Represent a tracked device.
Not intended to be directly inherited by integrations. Integrations should
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
"""
"""Represent a tracked device."""
_attr_device_info: None = None
_attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -308,28 +304,6 @@ class TrackerEntity(
return attr
class BaseScannerEntity(BaseTrackerEntity):
"""Base class for a tracked device that can be connected or disconnected.
Unlike ScannerEntity, this entity does not make assumptions about MAC
addresses being used to identify the device.
"""
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool | None:
"""Return true if the device is connected."""
raise NotImplementedError
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
@@ -342,7 +316,7 @@ CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
class ScannerEntity(
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
BaseTrackerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device that is on a scanned network."""
@@ -367,6 +341,18 @@ class ScannerEntity(
"""Return hostname of the device."""
return self._attr_hostname
@property
def state(self) -> str:
"""Return the state of the device."""
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
raise NotImplementedError
@property
def unique_id(self) -> str | None:
"""Return unique ID of the entity."""
+1 -1
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.1",
"aiodiscover==3.2.0",
"aiodiscover==2.7.1",
"cached-ipaddress==1.0.1"
]
}
+6 -99
View File
@@ -1,119 +1,26 @@
"""The DNS IP integration."""
import asyncio
from dataclasses import dataclass
import logging
import aiodns
from aiodns.error import DNSError
from pycares import AresError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.core import _LOGGER, HomeAssistant
from .const import (
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
CONF_PORT_IPV6,
CONF_RESOLVER,
CONF_RESOLVER_IPV6,
DEFAULT_PORT,
PLATFORMS,
)
_LOGGER = logging.getLogger(__name__)
from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS
@dataclass
class DnsIPRuntimeData:
"""Runtime data for DNS IP integration."""
resolver_ipv4: aiodns.DNSResolver | None
resolver_ipv6: aiodns.DNSResolver | None
type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up DNS IP from a config entry."""
hostname = entry.data[CONF_HOSTNAME]
resolver_ipv4: aiodns.DNSResolver | None = None
resolver_ipv6: aiodns.DNSResolver | None = None
queries: list = []
if entry.data[CONF_IPV4]:
resolver_ipv4 = aiodns.DNSResolver(
nameservers=[entry.options[CONF_RESOLVER]],
tcp_port=entry.options[CONF_PORT],
udp_port=entry.options[CONF_PORT],
)
queries.append(resolver_ipv4.query(hostname, "A"))
if entry.data[CONF_IPV6]:
resolver_ipv6 = aiodns.DNSResolver(
nameservers=[entry.options[CONF_RESOLVER_IPV6]],
tcp_port=entry.options[CONF_PORT_IPV6],
udp_port=entry.options[CONF_PORT_IPV6],
)
queries.append(resolver_ipv6.query(hostname, "AAAA"))
async def _close_resolvers() -> None:
if resolver_ipv4 is not None:
await resolver_ipv4.close()
if resolver_ipv6 is not None:
await resolver_ipv6.close()
try:
async with asyncio.timeout(10):
results = await asyncio.gather(*queries, return_exceptions=True)
except TimeoutError as err:
await _close_resolvers()
raise ConfigEntryNotReady(
f"DNS lookup timed out for {hostname}: {err}"
) from err
errors = [
result
for result in results
if isinstance(
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
)
]
if errors and len(errors) == len(results):
await _close_resolvers()
raise ConfigEntryNotReady(
f"DNS lookup failed for {hostname}: {errors[0]}"
) from errors[0]
entry.runtime_data = DnsIPRuntimeData(
resolver_ipv4=resolver_ipv4,
resolver_ipv6=resolver_ipv6,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload DNS IP config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
if entry.runtime_data.resolver_ipv4 is not None:
await entry.runtime_data.resolver_ipv4.close()
if entry.runtime_data.resolver_ipv6 is not None:
await entry.runtime_data.resolver_ipv6.close()
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: DnsIPConfigEntry
) -> bool:
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry to a newer version."""
if config_entry.version > 1:
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodns==4.0.4"]
"requirements": ["aiodns==4.0.3"]
}
+19 -48
View File
@@ -4,18 +4,18 @@ import asyncio
from datetime import timedelta
from ipaddress import IPv4Address, IPv6Address
import logging
from typing import TYPE_CHECKING, Literal
from typing import Literal
import aiodns
from aiodns.error import DNSError
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DnsIPConfigEntry
from .const import (
CONF_HOSTNAME,
CONF_IPV4,
@@ -46,7 +46,7 @@ def sort_ips(ips: list, querytype: Literal["A", "AAAA"]) -> list:
async def async_setup_entry(
hass: HomeAssistant,
entry: DnsIPConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the dnsip sensor entry."""
@@ -54,29 +54,16 @@ async def async_setup_entry(
hostname = entry.data[CONF_HOSTNAME]
name = entry.data[CONF_NAME]
nameserver_ipv4 = entry.options[CONF_RESOLVER]
nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
port_ipv4 = entry.options[CONF_PORT]
port_ipv6 = entry.options[CONF_PORT_IPV6]
entities = []
if entry.data[CONF_IPV4]:
entities.append(
WanIpSensor(
entry,
name,
hostname,
entry.options[CONF_RESOLVER],
False,
entry.options[CONF_PORT],
)
)
entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4))
if entry.data[CONF_IPV6]:
entities.append(
WanIpSensor(
entry,
name,
hostname,
entry.options[CONF_RESOLVER_IPV6],
True,
entry.options[CONF_PORT_IPV6],
)
)
entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
async_add_entities(entities, update_before_add=True)
@@ -88,9 +75,10 @@ class WanIpSensor(SensorEntity):
_attr_translation_key = "dnsip"
_unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
resolver: aiodns.DNSResolver
def __init__(
self,
entry: DnsIPConfigEntry,
name: str,
hostname: str,
nameserver: str,
@@ -98,8 +86,6 @@ class WanIpSensor(SensorEntity):
port: int,
) -> None:
"""Initialize the DNS IP sensor."""
self.entry = entry
self.ipv6 = ipv6
self._attr_name = "IPv6" if ipv6 else None
self._attr_unique_id = f"{hostname}_{ipv6}"
self.hostname = hostname
@@ -118,43 +104,28 @@ class WanIpSensor(SensorEntity):
model=aiodns.__version__,
name=name,
)
@property
def _resolver(self) -> aiodns.DNSResolver:
"""Return the active DNS resolver from runtime data."""
resolver = (
self.entry.runtime_data.resolver_ipv6
if self.ipv6
else self.entry.runtime_data.resolver_ipv4
)
if TYPE_CHECKING:
assert resolver is not None
return resolver
self.create_dns_resolver()
def create_dns_resolver(self) -> None:
"""Create a new DNS resolver and store it on runtime data."""
new_resolver = aiodns.DNSResolver(
"""Create the DNS resolver."""
self.resolver = aiodns.DNSResolver(
nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
)
if self.ipv6:
self.entry.runtime_data.resolver_ipv6 = new_resolver
else:
self.entry.runtime_data.resolver_ipv4 = new_resolver
async def async_update(self) -> None:
"""Get the current DNS IP address for hostname."""
if self._resolver._closed: # noqa: SLF001
if self.resolver._closed: # noqa: SLF001
self.create_dns_resolver()
response = None
try:
async with asyncio.timeout(10):
response = await self._resolver.query(self.hostname, self.querytype)
response = await self.resolver.query(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
await self._resolver.close()
await self.resolver.close()
except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err)
await self._resolver.close()
await self.resolver.close()
if response:
sorted_ips = sort_ips(
+17 -26
View File
@@ -3,12 +3,13 @@
from http import HTTPStatus
import os
import re
import threading
import requests
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
@@ -28,8 +29,8 @@ from .const import (
)
async def download_file(service: ServiceCall) -> None:
"""Download file specified in the URL."""
def download_file(service: ServiceCall) -> None:
"""Start thread to download file specified in the URL."""
entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
download_path = entry.data[CONF_DOWNLOAD_DIR]
@@ -123,7 +124,18 @@ async def download_file(service: ServiceCall) -> None:
{"url": url, "filename": filename},
)
except requests.exceptions.ConnectionError as err:
except requests.exceptions.ConnectionError:
_LOGGER.exception("ConnectionError occurred for %s", url)
service.hass.bus.fire(
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
{"url": url, "filename": filename},
)
# Remove file if we started downloading but failed
if final_path and os.path.isfile(final_path):
os.remove(final_path)
except ValueError:
_LOGGER.exception("Invalid value")
service.hass.bus.fire(
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
{"url": url, "filename": filename},
@@ -133,28 +145,7 @@ async def download_file(service: ServiceCall) -> None:
if final_path and os.path.isfile(final_path):
os.remove(final_path)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={"url": url},
) from err
except ValueError as err:
service.hass.bus.fire(
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
{"url": url, "filename": filename},
)
# Remove file if we started downloading but failed
if final_path and os.path.isfile(final_path):
os.remove(final_path)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_value",
translation_placeholders={"url": url},
) from err
await service.hass.async_add_executor_job(do_download)
threading.Thread(target=do_download).start()
@callback
@@ -13,12 +13,6 @@
}
},
"exceptions": {
"connection_error": {
"message": "Connection error occurred while downloading {url}"
},
"invalid_value": {
"message": "Invalid filename derived from {url}"
},
"subdir_invalid": {
"message": "Invalid subdirectory, got: {subdir}"
},
@@ -4,7 +4,6 @@
CONF_COMMAND_TOPIC = "drop_command_topic"
CONF_DATA_TOPIC = "drop_data_topic"
CONF_DEVICE_DESC = "device_desc"
# pylint: disable-next=home-assistant-duplicate-const
CONF_DEVICE_ID = "device_id"
CONF_DEVICE_TYPE = "device_type"
CONF_HUB_ID = "drop_hub_id"
@@ -49,7 +49,6 @@ CURRENT_SYSTEM_PRESSURE = "current_system_pressure"
HIGH_SYSTEM_PRESSURE = "high_system_pressure"
LOW_SYSTEM_PRESSURE = "low_system_pressure"
BATTERY = "battery"
# pylint: disable-next=home-assistant-duplicate-const
TEMPERATURE = "temperature"
INLET_TDS = "inlet_tds"
OUTLET_TDS = "outlet_tds"
+1 -5
View File
@@ -60,11 +60,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
raise ConfigEntryError(f"Duco API error: {err}") from err
async def _async_update_data(self) -> DucoData:
"""Fetch node data from the Duco box."""
@@ -1,10 +1,8 @@
"""Constants for the ElevenLabs text-to-speech integration."""
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
CONF_VOICE = "voice"
# pylint: disable-next=home-assistant-duplicate-const
CONF_MODEL = "model"
CONF_CONFIGURE_VOICE = "configure_voice"
CONF_STABILITY = "stability"
@@ -16,7 +16,6 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.const import SERVICE_ALARM_ARM_VACATION
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -44,6 +43,7 @@ DISPLAY_MESSAGE_SERVICE_SCHEMA: VolDictType = {
}
SERVICE_ALARM_DISPLAY_MESSAGE = "alarm_display_message"
SERVICE_ALARM_ARM_VACATION = "alarm_arm_vacation"
SERVICE_ALARM_ARM_HOME_INSTANT = "alarm_arm_home_instant"
SERVICE_ALARM_ARM_NIGHT_INSTANT = "alarm_arm_night_instant"
SERVICE_ALARM_BYPASS = "alarm_bypass"
@@ -10,7 +10,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_ADDRESS,
CONF_DEVICE,
CONF_HOST,
CONF_PASSWORD,
CONF_PREFIX,
@@ -33,6 +32,8 @@ from .discovery import (
async_update_entry_from_discovery,
)
CONF_DEVICE = "device"
NON_SECURE_PORT = 2101
SECURE_PORT = 2601
STANDARD_PORTS = {NON_SECURE_PORT, SECURE_PORT}
+13 -18
View File
@@ -47,23 +47,6 @@ def create_elk_entities(
return entities
def generate_unique_id(prefix: str, element: Element) -> str:
"""Generate a unique id."""
# unique_id starts with elkm1_ iff there is no prefix
# it starts with elkm1m_{prefix} iff there is a prefix
# this is to avoid a conflict between
# prefix=foo, name=bar (which would be elkm1_foo_bar)
# - and -
# prefix="", name="foo bar" (which would be elkm1_foo_bar also)
# we could have used elkm1__foo_bar for the latter, but that
# would have been a breaking change
if prefix != "":
uid_start = f"elkm1m_{prefix}"
else:
uid_start = "elkm1"
return f"{uid_start}_{element.default_name('_')}".lower()
class ElkEntity(Entity):
"""Base class for all Elk entities."""
@@ -77,7 +60,19 @@ class ElkEntity(Entity):
self._mac = elk_data.mac
self._prefix = elk_data.prefix
self._temperature_unit: str = elk_data.config["temperature_unit"]
self._unique_id = generate_unique_id(self._prefix, element)
# unique_id starts with elkm1_ iff there is no prefix
# it starts with elkm1m_{prefix} iff there is a prefix
# this is to avoid a conflict between
# prefix=foo, name=bar (which would be elkm1_foo_bar)
# - and -
# prefix="", name="foo bar" (which would be elkm1_foo_bar also)
# we could have used elkm1__foo_bar for the latter, but that
# would have been a breaking change
if self._prefix != "":
uid_start = f"elkm1m_{self._prefix}"
else:
uid_start = "elkm1"
self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower()
self._attr_name = element.name
@property
+4 -36
View File
@@ -1,6 +1,6 @@
"""Support for control of ElkM1 sensors."""
from typing import Any, cast
from typing import Any
from elkm1_lib.const import SettingFormat, ZoneType
from elkm1_lib.counters import Counter
@@ -20,19 +20,13 @@ from homeassistant.components.sensor import (
from homeassistant.const import EntityCategory, UnitOfElectricPotential
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import ElkM1ConfigEntry
from .const import ATTR_VALUE, ELK_USER_CODE_SERVICE_SCHEMA
from .entity import (
ElkAttachedEntity,
ElkEntity,
create_elk_entities,
generate_unique_id,
)
from .util import deprecate_entity
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh"
SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set"
@@ -64,37 +58,11 @@ async def async_setup_entry(
elk_data = config_entry.runtime_data
elk = elk_data.elk
entities: list[ElkEntity] = []
elk_settings: list[Setting] = []
create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities)
create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities)
create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities)
create_elk_entities(elk_data, elk.settings, "setting", ElkSetting, entities)
create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities)
entity_registry = er.async_get(hass)
for setting in elk.settings:
setting = cast(Setting, setting)
domain = (
"time" if setting.value_format == SettingFormat.TIME_OF_DAY else "number"
)
orig_unique_id = generate_unique_id(elk_data.prefix, setting)
new_unique_id = orig_unique_id
new_entity_id = f"{domain}.elkm1_{setting.name.replace(' ', '_')}".lower()
if deprecate_entity(
hass,
entity_registry,
"sensor",
orig_unique_id,
f"deprecated_sensor_{orig_unique_id}",
"deprecated_sensor",
new_unique_id,
new_entity_id,
):
elk_settings.append(setting)
create_elk_entities(elk_data, elk_settings, "setting", ElkSetting, entities)
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
@@ -58,16 +58,6 @@
}
}
},
"issues": {
"deprecated_sensor": {
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nUpdate your dashboards, templates, automations and scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
"title": "Deprecated sensor detected"
},
"deprecated_sensor_scripts": {
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nUpdate the above automations or scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
"title": "[%key:component::elkm1::issues::deprecated_sensor::title%]"
}
},
"services": {
"alarm_arm_home_instant": {
"description": "Arms the Elk-M1 in home instant mode.",
-102
View File
@@ -1,102 +0,0 @@
"""Utility helpers for the elkm1 integration."""
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .const import DOMAIN
def deprecate_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
platform_domain: str,
entity_unique_id: str,
issue_id: str,
issue_string: str,
replacement_entity_unique_id: str,
replacement_entity_id: str,
version: str = "2026.11.0",
) -> bool:
"""Create an issue for deprecated entities."""
if entity_id := entity_registry.async_get_entity_id(
platform_domain, DOMAIN, entity_unique_id
):
entity_entry = entity_registry.async_get(entity_id)
if not entity_entry:
async_delete_issue(hass, DOMAIN, issue_id)
return False
items = get_automations_and_scripts_using_entity(hass, entity_id)
if entity_entry.disabled and not items:
entity_registry.async_remove(entity_id)
async_delete_issue(hass, DOMAIN, issue_id)
return False
translation_key = issue_string
placeholders = {
"entity_id": entity_id,
"entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
"replacement_entity_id": (
entity_registry.async_get_entity_id(
Platform.NUMBER, DOMAIN, replacement_entity_unique_id
)
or entity_registry.async_get_entity_id(
Platform.TIME, DOMAIN, replacement_entity_unique_id
)
or replacement_entity_id
),
}
if items:
translation_key = f"{translation_key}_scripts"
placeholders["items"] = "\n".join(items)
async_create_issue(
hass,
DOMAIN,
issue_id,
breaks_in_ha_version=version,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=placeholders,
)
return True
async_delete_issue(hass, DOMAIN, issue_id)
return False
def get_automations_and_scripts_using_entity(
hass: HomeAssistant,
entity_id: str,
) -> list[str]:
"""Get automations and scripts using an entity."""
automations = automations_with_entity(hass, entity_id)
scripts = scripts_with_entity(hass, entity_id)
if not automations and not scripts:
return []
entity_registry = er.async_get(hass)
items: list[str] = []
for integration, entities in (
("automation", automations),
("script", scripts),
):
for used_entity_id in entities:
if item := entity_registry.async_get(used_entity_id):
items.append(
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
)
else:
items.append(f"- `{used_entity_id}`")
return items
-12
View File
@@ -138,9 +138,6 @@ class GridSourceType(TypedDict):
cost_adjustment_day: float
# An optional custom name for display in energy graphs
name: NotRequired[str]
class SolarSourceType(TypedDict):
"""Dictionary holding the source of energy production."""
@@ -151,9 +148,6 @@ class SolarSourceType(TypedDict):
stat_rate: NotRequired[str]
config_entry_solar_forecast: list[str] | None
# An optional custom name for display in energy graphs
name: NotRequired[str]
class BatterySourceType(TypedDict):
"""Dictionary holding the source of battery storage."""
@@ -172,9 +166,6 @@ class BatterySourceType(TypedDict):
# statistic_id of a sensor (unit %) reporting the battery state of charge
stat_soc: NotRequired[str]
# An optional custom name for display in energy graphs
name: NotRequired[str]
class GasSourceType(TypedDict):
"""Dictionary holding the source of gas consumption."""
@@ -473,7 +464,6 @@ GRID_SOURCE_SCHEMA = vol.All(
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
vol.Required("cost_adjustment_day"): vol.Coerce(float),
vol.Optional("name"): str,
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
@@ -493,7 +483,6 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
vol.Optional("name"): str,
}
)
BATTERY_SOURCE_SCHEMA = vol.Schema(
@@ -506,7 +495,6 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
vol.Optional("stat_soc"): str,
vol.Optional("name"): str,
}
)

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