mirror of
https://github.com/home-assistant/core.git
synced 2026-05-20 07:45:09 +02:00
Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd93c112ed | |||
| fcc0ab5452 | |||
| 26804ab408 | |||
| 177dcbc751 | |||
| 6d64d98250 | |||
| 8234c61ca8 | |||
| 99d6be1097 | |||
| e720c1b378 | |||
| 4a0ba0a830 | |||
| 4fb1aa6923 | |||
| 1042ec2964 | |||
| f4fdd4d58f | |||
| 3963555b2f | |||
| 4f8885b40d | |||
| 3f49877ff1 | |||
| d2bb31d115 | |||
| f499dbf29f | |||
| bc0e3dc3be | |||
| fb6e6170bf | |||
| 9e22711874 | |||
| 1982dd9085 | |||
| c32098decd | |||
| 2e87750d70 | |||
| 55354770a8 | |||
| d7b63a40db | |||
| c80d1ba003 | |||
| e675423c3c | |||
| 11cbf91563 | |||
| 4d5c36a3c1 | |||
| cc335a3bd9 | |||
| f764a32564 | |||
| aeb7109708 | |||
| f75c205c08 | |||
| e20f4c8f6e | |||
| 72f6c38e7d | |||
| 40408def0f | |||
| 282737e3c4 | |||
| a1cc735337 | |||
| b6f4551a76 | |||
| f5d2aa9c12 | |||
| 612dbf2d44 | |||
| f2691e4feb | |||
| f9654e15a6 | |||
| 01dde25ffa | |||
| 34254c138f | |||
| 1076d65c9c | |||
| ad71e31bad | |||
| 7608d5f99d | |||
| cafcbf8179 | |||
| 852faa7f95 | |||
| 5cf1e185f0 | |||
| c4d25a5a26 | |||
| 18f8e11865 | |||
| e8f3d357c4 | |||
| 1ad81697f7 | |||
| f66652c729 | |||
| c468ae77f3 | |||
| 251d7e15d2 | |||
| d268f8b486 | |||
| 6f3dfab487 | |||
| 8d8b9bb2e8 | |||
| 8c9d659dcf | |||
| f08adfe712 | |||
| de29414b37 | |||
| 01d9c2e810 | |||
| 9b3b3eca6d | |||
| 2e45ce36a7 | |||
| fe56ce6813 | |||
| 8000b419ea | |||
| f0a5ce747e | |||
| 7da5b10b51 | |||
| 94b373641d | |||
| dfd241dd1a | |||
| 27b161bf7c | |||
| f2362aa2a3 | |||
| 90946c3e2f | |||
| 318091689c | |||
| ee8c3ca864 | |||
| 5f6f300a20 | |||
| ad04aeced9 | |||
| bbb31f2910 | |||
| 0ed81e426b | |||
| 4582c56c1c | |||
| 9ce3e00e87 | |||
| bd2ea9a148 | |||
| e34be91439 | |||
| 3e5beb9aa3 | |||
| ac5df83d1a | |||
| c9e014c5d8 | |||
| 1b7564dcdf | |||
| 71425dd19f | |||
| eea08a0457 | |||
| 00132b4416 | |||
| 6b9efed899 | |||
| b0b6b46152 | |||
| 044ef25cb6 | |||
| b633fbcf07 | |||
| 7c9b6ad2a8 | |||
| 89d9fff1e9 | |||
| e0af3dfa99 | |||
| 4fb3ad102c | |||
| dc2ab012fa | |||
| 140fef6915 | |||
| 822a567ca9 | |||
| aa8904b0cd | |||
| e9f9194b7b | |||
| d0f4cba32c | |||
| beba530a9a | |||
| 5d3fd5a487 | |||
| bed6af2ef2 | |||
| 2b20b69928 | |||
| d5d50ac11a | |||
| ba5a62ec2a | |||
| 88ca0faea0 | |||
| a333f31d44 | |||
| 8854ad5765 |
+1
-1
@@ -14,7 +14,6 @@ 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
|
||||
@@ -23,3 +22,4 @@ 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
|
||||
|
||||
@@ -11,3 +11,6 @@ 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/**"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"pep621",
|
||||
"pip_requirements",
|
||||
"pre-commit",
|
||||
"dockerfile",
|
||||
"custom.regex",
|
||||
"homeassistant-manifest"
|
||||
],
|
||||
@@ -21,6 +22,10 @@
|
||||
]
|
||||
},
|
||||
|
||||
"dockerfile": {
|
||||
"managerFilePatterns": ["/^Dockerfile$/"]
|
||||
},
|
||||
|
||||
"homeassistant-manifest": {
|
||||
"managerFilePatterns": [
|
||||
"/^homeassistant/components/[^/]+/manifest\\.json$/"
|
||||
@@ -35,6 +40,14 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -184,6 +197,13 @@
|
||||
"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"],
|
||||
@@ -213,6 +233,12 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1371
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,405 @@
|
||||
---
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "requirements*.txt"
|
||||
- "homeassistant/package_constraints.txt"
|
||||
- "pyproject.toml"
|
||||
forks: ["*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pull_request_number:
|
||||
description: "Pull request number to (re-)check"
|
||||
required: true
|
||||
type: number
|
||||
roles: all
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
network:
|
||||
allowed:
|
||||
- python
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [default]
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
max: 1
|
||||
description: >
|
||||
Checks changed Python package requirements on PRs targeting the core repo
|
||||
(including PRs opened from forks) and verifies licenses match PyPI metadata, source
|
||||
repositories are publicly accessible, PyPI releases were uploaded via
|
||||
automated CI (Trusted Publisher attestation), the package's release pipeline
|
||||
uses OIDC or equivalent automated credentials (not static tokens), and the PR
|
||||
description contains the required links.
|
||||
---
|
||||
|
||||
# Requirements License and Availability Check
|
||||
|
||||
You are a code review assistant for the Home Assistant project. Your job is to
|
||||
review changes to Python package requirements and verify they meet the project's
|
||||
standards.
|
||||
|
||||
## Context
|
||||
|
||||
- Home Assistant uses `requirements_all.txt` (all integration packages),
|
||||
`requirements.txt` (core packages), `requirements_test.txt` (test
|
||||
dependencies), and `requirements_test_all.txt` (all test dependencies) to
|
||||
declare Python dependencies.
|
||||
- Each integration lists its packages in `homeassistant/components/<name>/manifest.json`
|
||||
under the `requirements` field.
|
||||
- Allowed licenses are maintained in `script/licenses.py` under
|
||||
`OSI_APPROVED_LICENSES_SPDX` (SPDX identifiers) and `OSI_APPROVED_LICENSES`
|
||||
(classifier strings).
|
||||
|
||||
## Step 1 — Identify Changed Packages
|
||||
|
||||
Use the GitHub tool to fetch the PR diff. Look for lines that were added (`+`)
|
||||
or removed (`-`) in **any** of these files:
|
||||
- `requirements.txt`
|
||||
- `requirements_all.txt`
|
||||
- `requirements_test.txt`
|
||||
- `requirements_test_all.txt`
|
||||
- `homeassistant/package_constraints.txt`
|
||||
- `pyproject.toml`
|
||||
|
||||
For each changed line that contains a package pin (e.g. `SomePackage==1.2.3`),
|
||||
classify it as:
|
||||
- **New package**: the package name appears only in `+` lines, with no
|
||||
corresponding `-` line for the same package name.
|
||||
- **Version bump**: the same package name appears in both `+` lines (new
|
||||
version) and `-` lines (old version), with different version numbers.
|
||||
|
||||
Record the **old version** and **new version** for every version bump — you
|
||||
will need these values in Step 4.
|
||||
|
||||
|
||||
## Step 2 — Check License via PyPI
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. Fetch `https://pypi.org/pypi/{package_name}/json` (use the exact
|
||||
package name as it appears on the requirements file).
|
||||
2. From the JSON response, extract:
|
||||
- `info.license` — free-text license field
|
||||
- `info.license_expression` — SPDX expression (if present)
|
||||
- `info.classifiers` — filter for entries starting with `"License ::"`,
|
||||
then normalize each match the same way as `script/licenses.py` by
|
||||
extracting the final ` :: ` segment (for example,
|
||||
`"License :: OSI Approved :: MIT License"` → `"MIT License"`).
|
||||
3. Determine if the license is in the approved list from `script/licenses.py`:
|
||||
- SPDX identifiers: compare against `OSI_APPROVED_LICENSES_SPDX`
|
||||
- Normalized classifier strings: compare against `OSI_APPROVED_LICENSES`
|
||||
4. Flag a package as ❌ if the license is unknown, missing, or not in the
|
||||
approved list. Flag as ⚠️ if the license information is ambiguous or cannot
|
||||
be definitively determined.
|
||||
|
||||
## Step 2b — Verify PyPI Release Was Uploaded by CI
|
||||
|
||||
For each new or bumped package, verify that the release on PyPI was published
|
||||
automatically by a CI pipeline (via OIDC Trusted Publisher), not uploaded
|
||||
manually.
|
||||
|
||||
1. Fetch the PyPI JSON for the specific version being introduced or bumped:
|
||||
`https://pypi.org/pypi/{package_name}/{version}/json`
|
||||
2. Inspect the `urls` array in the response. For each distribution file (wheel
|
||||
or sdist), note the filename.
|
||||
3. For each filename, attempt to fetch the PyPI provenance attestation:
|
||||
`https://pypi.org/integrity/{package_name}/{version}/{filename}/provenance`
|
||||
- If the response is HTTP 200 and contains a valid attestation object,
|
||||
inspect `attestation_bundles[*].publisher`. A Trusted Publisher attestation
|
||||
will have a `kind` identifying the CI system (e.g. `"GitHub Actions"`,
|
||||
`"GitLab"`) and a `repository` or `project` field matching the source
|
||||
repository.
|
||||
- If at least one distribution file has a valid Trusted Publisher attestation,
|
||||
mark ✅ CI-uploaded.
|
||||
- If no attestation is found for any file (404 for all), mark ❌ — "Release
|
||||
has no provenance attestation; it may have been uploaded manually".
|
||||
- If an attestation exists but the `publisher` does not identify a recognized
|
||||
CI system or Trusted Publisher, mark ⚠️ — "Attestation present but
|
||||
publisher cannot be verified as automated CI".
|
||||
|
||||
Note: if PyPI returns an error fetching the per-version JSON, fall back to the
|
||||
latest JSON (`https://pypi.org/pypi/{package_name}/json`) and look up the
|
||||
specific version in the `releases` dict.
|
||||
|
||||
## Step 3 — Identify Repository URL
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. From the PyPI JSON at `info.project_urls`, find the source repository URL
|
||||
(keys such as `"Source"`, `"Homepage"`, `"Repository"`, or `"Source Code"`).
|
||||
2. Record that repository URL for later checks.
|
||||
3. If no suitable repository URL is present, mark ❌ with a note that the
|
||||
source repository URL is missing and cannot be verified.
|
||||
|
||||
## Step 4 — Check PR Description
|
||||
|
||||
Read the PR body from the GitHub API using the PR number from the workflow
|
||||
context (`pull-request-number`). If that value is absent, use the
|
||||
`workflow_dispatch` input `pull_request_number`.
|
||||
Extract all URLs present in the PR body.
|
||||
|
||||
### 4a — New packages: repository link required
|
||||
|
||||
For **new packages** (brand-new dependency not previously in any requirements
|
||||
file): the PR description must contain a link that points to the package's
|
||||
**source repository** as identified in Step 3 (the URL recorded from
|
||||
`info.project_urls`). A PyPI page link alone is **not** acceptable — the link
|
||||
must point directly to the source repository (e.g. a GitHub or GitLab URL).
|
||||
|
||||
- If a URL in the PR body matches (or is a sub-path of) the source repository
|
||||
URL identified via PyPI, mark ✅.
|
||||
- If the PR body contains a source repository URL that does **not** match the
|
||||
repository URL found in the package's PyPI metadata (`info.project_urls`),
|
||||
mark ❌ — "PR description links to `<pr_url>` but PyPI reports the source
|
||||
repository as `<pypi_repo_url>`; please use the correct repository URL."
|
||||
- If no source repository URL is present in the PR body at all, mark ❌ —
|
||||
"PR description must link to the source repository at `<repo_url>` (found
|
||||
via PyPI). A PyPI page link is not sufficient."
|
||||
|
||||
### 4b — Version bumps: changelog or diff link required
|
||||
|
||||
For **version bumps**: the PR description must contain a link to a changelog,
|
||||
release notes page, or a diff/comparison URL that references the **correct
|
||||
versions** being bumped (old → new).
|
||||
|
||||
Checks to perform for each bumped package (old version = X, new version = Y):
|
||||
1. Extract all URLs from the PR body that contain the repository's domain or
|
||||
path (as identified in Step 3).
|
||||
2. Verify that at least one such URL includes both the old version string and
|
||||
new version string in some form — e.g. a GitHub compare URL like
|
||||
`compare/vX...vY`, a releases URL mentioning version Y, or a
|
||||
`CHANGELOG.md` anchor referencing Y.
|
||||
3. If no URL matches, check if the PR body contains any changelog/diff link at
|
||||
all for this package.
|
||||
|
||||
Outcome:
|
||||
- ✅ — a URL pointing to the correct repo with version references covering the
|
||||
exact bump (X → Y).
|
||||
- ⚠️ — a changelog/diff link exists but does not clearly reference the correct
|
||||
versions or the correct repository; explain what was found and what is
|
||||
expected.
|
||||
- ❌ — no changelog or diff link found at all in the PR description for this
|
||||
package.
|
||||
|
||||
### 4c — Diff consistency check
|
||||
|
||||
For each **version bump**, verify that the version change recorded in the diff
|
||||
(Step 1) is internally consistent:
|
||||
- The `-` line must contain the old version and the `+` line must contain the
|
||||
new version for the same package name.
|
||||
- Flag ❌ if the diff shows a downgrade (new version < old version) without an
|
||||
explanation, or if the version strings cannot be parsed.
|
||||
|
||||
## Step 5 — Verify Source Repository is Publicly Accessible
|
||||
|
||||
Before inspecting the release pipeline, confirm that the source repository
|
||||
identified in Step 3 is publicly reachable.
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. Use the source repository URL recorded in Step 3.
|
||||
2. If no repository URL was found in `info.project_urls`, mark ❌ — "No source
|
||||
repository URL found in PyPI metadata; a public source repository is
|
||||
required."
|
||||
3. If a repository URL was found, perform a GET request to that URL (using
|
||||
web-fetch). If the response is HTTP 200 and returns a publicly accessible
|
||||
page (not a login redirect or error page), mark ✅.
|
||||
4. If the response is non-200, the URL redirects to a login/authentication page,
|
||||
or the repository appears private or unavailable, mark ❌ — "Source
|
||||
repository at `<repo_url>` is not publicly accessible. Home Assistant
|
||||
requires all dependencies to have publicly available source code." **Do not
|
||||
proceed with the release pipeline check (Step 6) for this package.**
|
||||
|
||||
## Step 6 — Check Release Pipeline Sanity
|
||||
|
||||
For each new or bumped package, determine the source repository host from the
|
||||
URL identified in Step 3, then inspect whether the project's release/publish CI
|
||||
workflow is sane. The checks differ by hosting provider.
|
||||
|
||||
### GitHub repositories (`github.com`)
|
||||
|
||||
1. Using the GitHub API, list the workflows in the source repository:
|
||||
`GET /repos/{owner}/{repo}/actions/workflows`
|
||||
2. Identify any workflow whose name or filename suggests publishing to PyPI
|
||||
(e.g., contains "release", "publish", "pypi", or "deploy").
|
||||
3. Fetch the workflow file content and check the following:
|
||||
a. **Trigger sanity**: The publish job should be triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job — **not** solely
|
||||
by `workflow_dispatch` with no additional guards. A `workflow_dispatch`
|
||||
trigger alongside other triggers is acceptable. Mark ❌ if the only trigger
|
||||
is manual `workflow_dispatch` with no environment protection rules.
|
||||
b. **OIDC / Trusted Publisher**: The workflow should use OIDC-based publishing.
|
||||
Look for `id-token: write` permission and one of:
|
||||
- `pypa/gh-action-pypi-publish` action
|
||||
- `actions/attest-build-provenance` action
|
||||
- Any step that sets `TWINE_PASSWORD` from `secrets.PYPI_TOKEN` directly
|
||||
(flag ❌ if a long-lived API token is used instead of OIDC).
|
||||
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined,
|
||||
❌ if a static secret token is the only credential.
|
||||
c. **No manual upload bypass**: Verify there is no step that calls
|
||||
`twine upload` or `pip upload` outside of a properly gated job (e.g., one
|
||||
that requires an environment approval). Flag ⚠️ if such steps exist.
|
||||
4. If no publish workflow is found in the repository, mark ⚠️ — "No publish
|
||||
workflow found; it is unclear how this package is released to PyPI."
|
||||
|
||||
### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Use the GitLab REST API to list CI/CD pipeline configuration files. First
|
||||
resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`
|
||||
and note the `id` field.
|
||||
2. Fetch the repository's `.gitlab-ci.yml` (and any included files) using
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`
|
||||
(use web-fetch for public repos).
|
||||
3. Identify any job whose name or `stage` suggests publishing to PyPI
|
||||
(e.g., "publish", "deploy", "release", "pypi").
|
||||
4. For each such job, check:
|
||||
a. **Trigger sanity**: The job should run only on tag pipelines (`only: tags`
|
||||
or `rules: - if: $CI_COMMIT_TAG`) or on protected branches — **not**
|
||||
solely on manual triggers (`when: manual`) with no additional protection.
|
||||
Mark ❌ if the only trigger is manual with no environment or protected-branch
|
||||
guard.
|
||||
b. **Automated credentials**: The job should use GitLab's OIDC ID token
|
||||
(`id_tokens:` block) and `pypa/gh-action-pypi-publish` equivalent, or
|
||||
reference `secrets.PYPI_TOKEN` / `$PYPI_TOKEN` injected from GitLab CI/CD
|
||||
protected variables (flag ❌ if the token is hard-coded or unprotected).
|
||||
Mark ✅ if OIDC or protected CI variables are used, ⚠️ if the method
|
||||
cannot be determined, ❌ if credentials appear to be insecure.
|
||||
c. **No manual upload bypass**: Flag ⚠️ if any job calls `twine upload`
|
||||
without being behind a protected-variable or environment guard.
|
||||
5. If no publish job is found, mark ⚠️ — "No publish job found in .gitlab-ci.yml;
|
||||
it is unclear how this package is released to PyPI."
|
||||
|
||||
### Other code hosting providers
|
||||
|
||||
For repositories hosted on platforms other than GitHub or GitLab (e.g.,
|
||||
Bitbucket, Codeberg, Gitea, Sourcehut):
|
||||
1. Use web-fetch to retrieve the repository's root page and look for any
|
||||
publicly visible CI configuration files (e.g., `.circleci/config.yml`,
|
||||
`Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`,
|
||||
`.builds/*.yml` for Sourcehut).
|
||||
2. Apply the same conceptual checks as above:
|
||||
- Does publishing run on automated triggers (tags/releases), not solely
|
||||
manual ones?
|
||||
- Are credentials injected by the CI system (not hard-coded)?
|
||||
- Is there a `twine upload` or equivalent step that could be run manually?
|
||||
3. If no CI configuration can be retrieved, mark ⚠️ — "Release pipeline could
|
||||
not be inspected; hosting provider is not GitHub or GitLab."
|
||||
|
||||
## Step 7 — Post a Review Comment
|
||||
|
||||
**Always** post a review comment using `add_comment`, regardless of whether
|
||||
packages pass or fail. Use the following structure:
|
||||
|
||||
**Note on deduplication**: The workflow automatically updates any previous
|
||||
requirements-check comment on the PR in place (preserving its position in the
|
||||
thread). If no previous comment exists, the newly created comment is kept as-is.
|
||||
You do not need to search for or update previous comments yourself.
|
||||
|
||||
### Comment structure
|
||||
|
||||
Begin every comment with the HTML marker `<!-- requirements-check -->` on its
|
||||
own line (this is used by the workflow to find the previous comment and update
|
||||
it on the next run).
|
||||
|
||||
### 7a — Overall summary line
|
||||
|
||||
Begin the comment with a single summary line, before anything else:
|
||||
|
||||
- If everything passed: `All requirements checks passed. ✅`
|
||||
- If there are failures or warnings: `⚠️ Some checks require attention — see the details below.`
|
||||
|
||||
### 7b — Summary table
|
||||
|
||||
Render a compact table where every check column contains **only the status
|
||||
icon** (✅, ⚠️, or ❌). No explanatory text belongs inside the table cells —
|
||||
all detail goes in the per-package sections below.
|
||||
|
||||
Use `—` (em dash) when a check was skipped (e.g. Release Pipeline is skipped
|
||||
when the repository is not publicly accessible).
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Requirements Check
|
||||
|
||||
| Package | Type | Old→New | License | Repo Public | CI Upload | Release Pipeline | PR Link | Diff Consistent |
|
||||
|---------|------|---------|---------|-------------|-----------|------------------|---------|-----------------|
|
||||
| PackageA | bump | 1.2.3→1.3.0 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| PackageB | new | —→4.5.6 | ❌ | ✅ | ❌ | ⚠️ | ❌ | ✅ |
|
||||
| PackageC | bump | 2.0.0→2.1.0 | ✅ | ❌ | — | — | ⚠️ | ✅ |
|
||||
```
|
||||
|
||||
### 7c — Per-package detail sections
|
||||
|
||||
After the table, add one collapsible `<details>` block per package.
|
||||
|
||||
- If **all checks passed** for that package, render the block **collapsed**
|
||||
(no `open` attribute) so the comment stays concise.
|
||||
- If **any check failed or produced a warning**, render the block **open**
|
||||
(`<details open>`) so the contributor sees the issues immediately.
|
||||
|
||||
Each block must include the full detail for every check: the license found, the
|
||||
repository URL, whether a provenance attestation was found, the release
|
||||
pipeline findings, the PR link found (or missing), and whether the diff is
|
||||
consistent. For failed or warned checks, explain exactly what the contributor
|
||||
must fix, including the expected source repository URL, expected version range,
|
||||
etc.
|
||||
|
||||
Template (repeat for each package):
|
||||
|
||||
```
|
||||
<details open>
|
||||
<summary><strong>PackageB 📦 new —→4.5.6</strong></summary>
|
||||
|
||||
- **License**: ❌ License is `UNKNOWN` — not in the approved list. Check PyPI metadata and `script/licenses.py`.
|
||||
- **Repository Public**: ✅ https://github.com/example/packageb is publicly accessible.
|
||||
- **CI Upload**: ❌ No provenance attestation found for any distribution file. The release may have been uploaded manually.
|
||||
- **Release Pipeline**: ⚠️ No publish workflow found in the repository; it is unclear how this package is released to PyPI.
|
||||
- **PR Link**: ❌ PR description must link to the source repository at https://github.com/example/packageb (a PyPI page link is not sufficient).
|
||||
- **Diff Consistent**: ✅
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
Collapsed example (all checks passed):
|
||||
|
||||
```
|
||||
<details>
|
||||
<summary><strong>PackageA 📦 bump 1.2.3→1.3.0</strong></summary>
|
||||
|
||||
- **License**: ✅ MIT
|
||||
- **Repository Public**: ✅ https://github.com/example/packagea
|
||||
- **CI Upload**: ✅ Trusted Publisher attestation found (GitHub Actions).
|
||||
- **Release Pipeline**: ✅ OIDC via `pypa/gh-action-pypi-publish`; triggered on `release: published`; `environment: release` gate.
|
||||
- **PR Link**: ✅ https://github.com/example/packagea/compare/v1.2.3...v1.3.0
|
||||
- **Diff Consistent**: ✅
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive and helpful. Provide direct links where possible so the
|
||||
contributor can quickly fix the issue.
|
||||
- If PyPI returns an error for a package, mention that it could not be found and
|
||||
suggest the contributor verify the package name.
|
||||
- For packages that only appear in `homeassistant/package_constraints.txt` or
|
||||
`pyproject.toml` without being tied to a specific integration, the PR
|
||||
description link requirement still applies.
|
||||
- When checking test-only packages (from `requirements_test.txt` or
|
||||
`requirements_test_all.txt`), apply the same license, repository, and PR
|
||||
description checks as for production dependencies.
|
||||
- A package that appears in both a production file and a test file should only
|
||||
be reported once; use the production file entry as the canonical one.
|
||||
- This workflow is only triggered when a commit actually changes one of the
|
||||
tracked requirements files (for `synchronize` events GitHub compares the
|
||||
before/after SHAs of the push, not the entire PR diff). Members can manually
|
||||
retrigger the workflow via `workflow_dispatch` with the PR number to re-run
|
||||
the check after updating the PR description or fixing issues without changing
|
||||
any requirements files. On a retrigger the existing comment is updated in
|
||||
place so there is always exactly one requirements-check comment in the PR.
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.12
|
||||
rev: v0.15.13
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
@@ -23,6 +23,7 @@ repos:
|
||||
- id: zizmor
|
||||
args:
|
||||
- --pedantic
|
||||
exclude: ^\.github/workflows/.*\.lock\.yml$
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
@@ -46,6 +47,7 @@ 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,5 +1,6 @@
|
||||
ignore: |
|
||||
tests/fixtures/core/config/yaml_errors/
|
||||
.github/workflows/*.lock.yml
|
||||
rules:
|
||||
braces:
|
||||
level: error
|
||||
|
||||
Generated
+4
@@ -68,6 +68,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/agent_dvr/ @ispysoftware
|
||||
/homeassistant/components/ai_task/ @home-assistant/core
|
||||
/tests/components/ai_task/ @home-assistant/core
|
||||
/homeassistant/components/aidot/ @s1eedz @HongBryan
|
||||
/tests/components/aidot/ @s1eedz @HongBryan
|
||||
/homeassistant/components/air_quality/ @home-assistant/core
|
||||
/tests/components/air_quality/ @home-assistant/core
|
||||
/homeassistant/components/airgradient/ @airgradienthq @joostlek
|
||||
@@ -2054,6 +2056,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/yi/ @bachya
|
||||
/homeassistant/components/yolink/ @matrixd2
|
||||
/tests/components/yolink/ @matrixd2
|
||||
/homeassistant/components/yoto/ @cdnninja @piitaya
|
||||
/tests/components/yoto/ @cdnninja @piitaya
|
||||
/homeassistant/components/youless/ @gjong
|
||||
/tests/components/youless/ @gjong
|
||||
/homeassistant/components/youtube/ @joostlek
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
|
||||
# Automatically generated by hassfest.
|
||||
# Partly 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@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
|
||||
COPY --from=ghcr.io/alexxit/go2rtc:1.9.14@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
## Setup Home Assistant Core dependencies
|
||||
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The aidot integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AidotConfigEntry, AidotDeviceManagerCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
|
||||
"""Set up aidot from a config entry."""
|
||||
|
||||
coordinator = AidotDeviceManagerCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.async_cleanup()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Config flow for Aidot integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aidot.client import AidotClient
|
||||
from aidot.const import CONF_ID, DEFAULT_COUNTRY_CODE, SUPPORTED_COUNTRY_CODES
|
||||
from aidot.exceptions import AidotUserOrPassIncorrect
|
||||
from aiohttp import ClientError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_COUNTRY_CODE,
|
||||
default=DEFAULT_COUNTRY_CODE,
|
||||
): selector.CountrySelector(
|
||||
selector.CountrySelectorConfig(
|
||||
countries=SUPPORTED_COUNTRY_CODES,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AidotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle aidot config flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
client = AidotClient(
|
||||
session=async_get_clientsession(self.hass),
|
||||
country_code=user_input[CONF_COUNTRY_CODE],
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
login_info = await client.async_post_login()
|
||||
except AidotUserOrPassIncorrect:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TimeoutError, ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(login_info[CONF_ID])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"{user_input[CONF_USERNAME]} {user_input[CONF_COUNTRY_CODE]}",
|
||||
data=login_info,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the aidot integration."""
|
||||
|
||||
DOMAIN = "aidot"
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Coordinator for Aidot."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aidot.client import AidotClient
|
||||
from aidot.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_AES_KEY,
|
||||
CONF_DEVICE_LIST,
|
||||
CONF_ID,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from aidot.device_client import DeviceClient, DeviceStatusData
|
||||
from aidot.exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type AidotConfigEntry = ConfigEntry[AidotDeviceManagerCoordinator]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_DEVICE_LIST_INTERVAL = timedelta(hours=6)
|
||||
|
||||
|
||||
class AidotDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceStatusData]):
|
||||
"""Class to manage Aidot data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AidotConfigEntry,
|
||||
device_client: DeviceClient,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=None,
|
||||
)
|
||||
self.device_client = device_client
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
self.device_client.on_status_update = self._handle_status_update
|
||||
|
||||
def _handle_status_update(self, status: DeviceStatusData) -> None:
|
||||
"""Handle status callback."""
|
||||
self.async_set_updated_data(status)
|
||||
|
||||
async def _async_update_data(self) -> DeviceStatusData:
|
||||
"""Return current status."""
|
||||
return self.device_client.status
|
||||
|
||||
|
||||
class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Class to manage fetching Aidot data."""
|
||||
|
||||
config_entry: AidotConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AidotConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_DEVICE_LIST_INTERVAL,
|
||||
)
|
||||
self.client = AidotClient(
|
||||
session=async_get_clientsession(hass),
|
||||
token=config_entry.data,
|
||||
)
|
||||
self.client.set_token_fresh_cb(self.token_fresh_cb)
|
||||
self.device_coordinators: dict[str, AidotDeviceUpdateCoordinator] = {}
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
try:
|
||||
await self.async_auto_login()
|
||||
except AidotUserOrPassIncorrect as error:
|
||||
raise ConfigEntryError from error
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update data async."""
|
||||
try:
|
||||
data = await self.client.async_get_all_device()
|
||||
except AidotAuthFailed as error:
|
||||
raise ConfigEntryError from error
|
||||
current_devices = {
|
||||
device[CONF_ID]: device
|
||||
for device in data[CONF_DEVICE_LIST]
|
||||
if (
|
||||
device[CONF_TYPE] == "light"
|
||||
and CONF_AES_KEY in device
|
||||
and device[CONF_AES_KEY][0] is not None
|
||||
)
|
||||
}
|
||||
|
||||
removed_ids = set(self.device_coordinators) - set(current_devices)
|
||||
for dev_id in removed_ids:
|
||||
coordinator = self.device_coordinators.pop(dev_id)
|
||||
coordinator.device_client.on_status_update = None
|
||||
if removed_ids:
|
||||
self._purge_deleted_lists()
|
||||
|
||||
for dev_id, device in current_devices.items():
|
||||
if dev_id not in self.device_coordinators:
|
||||
device_client = self.client.get_device_client(device)
|
||||
device_coordinator = AidotDeviceUpdateCoordinator(
|
||||
self.hass, self.config_entry, device_client
|
||||
)
|
||||
await device_coordinator.async_config_entry_first_refresh()
|
||||
self.device_coordinators[dev_id] = device_coordinator
|
||||
|
||||
async def async_cleanup(self) -> None:
|
||||
"""Perform cleanup actions."""
|
||||
for coordinator in self.device_coordinators.values():
|
||||
coordinator.device_client.on_status_update = None
|
||||
await self.client.async_cleanup()
|
||||
|
||||
def token_fresh_cb(self) -> None:
|
||||
"""Update token."""
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=self.client.login_info.copy()
|
||||
)
|
||||
|
||||
async def async_auto_login(self) -> None:
|
||||
"""Async auto login."""
|
||||
if self.client.login_info.get(CONF_ACCESS_TOKEN) is None:
|
||||
await self.client.async_post_login()
|
||||
|
||||
def _purge_deleted_lists(self) -> None:
|
||||
"""Purge device entries of deleted lists."""
|
||||
|
||||
device_reg = dr.async_get(self.hass)
|
||||
identifiers = {
|
||||
(
|
||||
DOMAIN,
|
||||
device_coordinator.device_client.info.dev_id,
|
||||
)
|
||||
for device_coordinator in self.device_coordinators.values()
|
||||
}
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_reg, self.config_entry.entry_id
|
||||
):
|
||||
if not set(device.identifiers) & identifiers:
|
||||
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Support for Aidot lights."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGBW_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AidotConfigEntry, AidotDeviceUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AidotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Light."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
AidotLight(device_coordinator)
|
||||
for device_coordinator in coordinator.device_coordinators.values()
|
||||
)
|
||||
|
||||
|
||||
class AidotLight(CoordinatorEntity[AidotDeviceUpdateCoordinator], LightEntity):
|
||||
"""Representation of a Aidot Wi-Fi Light."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: AidotDeviceUpdateCoordinator) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.device_client.info.dev_id
|
||||
if hasattr(coordinator.device_client.info, "cct_max"):
|
||||
self._attr_max_color_temp_kelvin = coordinator.device_client.info.cct_max
|
||||
if hasattr(coordinator.device_client.info, "cct_min"):
|
||||
self._attr_min_color_temp_kelvin = coordinator.device_client.info.cct_min
|
||||
|
||||
model_id = coordinator.device_client.info.model_id
|
||||
manufacturer = model_id.split(".")[0]
|
||||
model = model_id[len(manufacturer) + 1 :]
|
||||
mac = coordinator.device_client.info.mac
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
name=coordinator.device_client.info.name,
|
||||
hw_version=coordinator.device_client.info.hw_version,
|
||||
)
|
||||
if coordinator.device_client.info.enable_rgbw:
|
||||
self._attr_color_mode = ColorMode.RGBW
|
||||
self._attr_supported_color_modes = {ColorMode.RGBW, ColorMode.COLOR_TEMP}
|
||||
elif coordinator.device_client.info.enable_cct:
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update light status from coordinator data."""
|
||||
self._attr_is_on = self.coordinator.data.on
|
||||
self._attr_brightness = self.coordinator.data.dimming
|
||||
self._attr_color_temp_kelvin = self.coordinator.data.cct
|
||||
self._attr_rgbw_color = self.coordinator.data.rgbw
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.data.online
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update."""
|
||||
self._update_status()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on, applying brightness, color temperature, RGBW, or plain on."""
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
|
||||
await self.coordinator.device_client.async_set_brightness(brightness)
|
||||
self.coordinator.data.dimming = brightness
|
||||
self._attr_brightness = brightness
|
||||
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
await self.coordinator.device_client.async_set_cct(color_temp_kelvin)
|
||||
self.coordinator.data.cct = color_temp_kelvin
|
||||
self._attr_color_temp_kelvin = color_temp_kelvin
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
elif ATTR_RGBW_COLOR in kwargs:
|
||||
rgbw_color = kwargs.get(ATTR_RGBW_COLOR)
|
||||
await self.coordinator.device_client.async_set_rgbw(rgbw_color)
|
||||
self.coordinator.data.rgbw = rgbw_color
|
||||
self._attr_rgbw_color = rgbw_color
|
||||
self._attr_color_mode = ColorMode.RGBW
|
||||
else:
|
||||
await self.coordinator.device_client.async_turn_on()
|
||||
|
||||
self.coordinator.data.on = True
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self.coordinator.device_client.async_turn_off()
|
||||
self.coordinator.data.on = False
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "aidot",
|
||||
"name": "AiDot",
|
||||
"codeowners": ["@s1eedz", "@HongBryan"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/aidot",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-aidot==0.3.53"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: This integration does not register any events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration has no option flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
entity-disabled-by-default: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"country_code": "Country",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"country_code": "The country selected by AiDot app when logging in",
|
||||
"password": "Password for logging in through AiDot app",
|
||||
"username": "Account logged in through AiDot app"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,7 @@ from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import llm
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, llm
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
@@ -50,6 +49,8 @@ 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,
|
||||
@@ -452,11 +453,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_MAX_USES,
|
||||
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
|
||||
): int,
|
||||
): cv.positive_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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ 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"
|
||||
@@ -45,6 +47,8 @@ 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,
|
||||
|
||||
@@ -17,8 +17,6 @@ from anthropic.types import (
|
||||
Base64PDFSourceParam,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
CitationsDelta,
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
CodeExecutionTool20250825Param,
|
||||
CodeExecutionToolResultBlock,
|
||||
CodeExecutionToolResultBlockContent,
|
||||
@@ -70,6 +68,9 @@ from anthropic.types import (
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
WebFetchTool20250910Param,
|
||||
WebFetchTool20260209Param,
|
||||
WebFetchToolResultBlock,
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchTool20260209Param,
|
||||
WebSearchToolResultBlock,
|
||||
@@ -97,6 +98,12 @@ 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
|
||||
|
||||
@@ -118,6 +125,8 @@ 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,
|
||||
@@ -208,17 +217,9 @@ class ContentDetails:
|
||||
"""Add a citation to the current detail."""
|
||||
if not self.citation_details:
|
||||
self.citation_details.append(CitationDetails())
|
||||
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)
|
||||
self.citation_details[-1].citations.append(
|
||||
cast(TextCitationParam, citation.to_dict())
|
||||
)
|
||||
|
||||
def delete_empty(self) -> None:
|
||||
"""Delete empty citation details."""
|
||||
@@ -289,6 +290,15 @@ 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",
|
||||
@@ -415,6 +425,7 @@ def _convert_content( # noqa: C901
|
||||
id=tool_call.id,
|
||||
name=cast(
|
||||
Literal[
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
@@ -428,6 +439,7 @@ def _convert_content( # noqa: C901
|
||||
if tool_call.external
|
||||
and tool_call.tool_name
|
||||
in [
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
@@ -609,6 +621,7 @@ class AnthropicDeltaStream:
|
||||
if isinstance(
|
||||
content_block,
|
||||
(
|
||||
WebFetchToolResultBlock,
|
||||
WebSearchToolResultBlock,
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
@@ -724,13 +737,15 @@ 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: WebSearchToolResultBlockContent
|
||||
content: WebFetchToolResultBlockContent
|
||||
| WebSearchToolResultBlockContent
|
||||
| CodeExecutionToolResultBlockContent
|
||||
| BashCodeExecutionToolResultBlockContent
|
||||
| TextEditorCodeExecutionToolResultBlockContent
|
||||
@@ -907,6 +922,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
"GetLiveContext",
|
||||
"code_execution",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
@@ -980,12 +996,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
]
|
||||
|
||||
if options[CONF_CODE_EXECUTION]:
|
||||
# The `web_search_20260209` tool automatically enables
|
||||
# `code_execution_20260120` tool
|
||||
# The `web_search_20260209` and `web_fetch_20260209` tools
|
||||
# automatically enable `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]
|
||||
or (not options[CONF_WEB_SEARCH] and not options[CONF_WEB_FETCH])
|
||||
):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
@@ -1023,6 +1039,28 @@ 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:
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
"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%]"
|
||||
},
|
||||
@@ -90,6 +92,8 @@
|
||||
"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%]"
|
||||
},
|
||||
@@ -149,6 +153,8 @@
|
||||
"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"
|
||||
},
|
||||
@@ -159,6 +165,8 @@
|
||||
"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 CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -21,23 +21,33 @@ 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
|
||||
cb: CALLBACK_TYPE
|
||||
added = False
|
||||
|
||||
@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)]
|
||||
)
|
||||
cb()
|
||||
added = True
|
||||
|
||||
cb = async_dispatcher_connect(
|
||||
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
|
||||
config_entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
|
||||
)
|
||||
)
|
||||
config_entry.async_on_unload(cb)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
|
||||
|
||||
@@ -53,18 +53,19 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
self.state = State(client, zone)
|
||||
self.update_in_progress = False
|
||||
|
||||
name = config_entry.title
|
||||
device_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}"
|
||||
name += f" Zone {zone}"
|
||||
device_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=name,
|
||||
name=device_name,
|
||||
)
|
||||
self.zone_unique_id = f"{unique_id}-{zone}"
|
||||
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
"""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,11 +1,9 @@
|
||||
"""Arcam media player."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import functools
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arcam.fmj import ConnectionFailed, SourceCodes
|
||||
from arcam.fmj import SourceCodes
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
@@ -18,12 +16,12 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, EVENT_TURN_ON
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
|
||||
from .entity import ArcamFmjEntity
|
||||
from .entity import ArcamFmjEntity, convert_exception
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,23 +39,6 @@ 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."""
|
||||
|
||||
@@ -79,11 +60,17 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the state of the device."""
|
||||
if self._state.get_power():
|
||||
return MediaPlayerState.ON
|
||||
return MediaPlayerState.OFF
|
||||
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
|
||||
|
||||
@convert_exception
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
@@ -179,7 +166,7 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
]
|
||||
|
||||
return BrowseMedia(
|
||||
title="Arcam FMJ Receiver",
|
||||
title=self.coordinator.device_name,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id="root",
|
||||
media_content_type="library",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from autoskope_client.constants import MANUFACTURER
|
||||
from autoskope_client.models import Vehicle
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType, TrackerEntity
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -113,11 +113,6 @@ class AutoskopeDeviceTracker(
|
||||
return float(vehicle.position.longitude)
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device in meters."""
|
||||
|
||||
@@ -32,6 +32,7 @@ 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:
|
||||
@@ -41,6 +42,16 @@ 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(
|
||||
@@ -84,7 +95,9 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Avea light platform."""
|
||||
async_add_entities([AveaLight(entry.runtime_data)], update_before_add=True)
|
||||
async_add_entities(
|
||||
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
|
||||
)
|
||||
|
||||
|
||||
def _discover_bulbs_for_import() -> list[dict[str, str]]:
|
||||
@@ -169,30 +182,47 @@ class AveaLight(LightEntity):
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, light: avea.Bulb) -> None:
|
||||
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
|
||||
"""Initialize an AveaLight."""
|
||||
self._light = light
|
||||
self._attr_name = light.name
|
||||
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(4095)
|
||||
self._light.set_brightness(_ha_brightness_to_avea(self._last_brightness))
|
||||
else:
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
bright = round((kwargs[ATTR_BRIGHTNESS] / 255) * 4095)
|
||||
self._light.set_brightness(bright)
|
||||
brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
if brightness:
|
||||
self._last_brightness = brightness
|
||||
self._light.set_brightness(_ha_brightness_to_avea(brightness))
|
||||
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])
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
if self._attr_brightness:
|
||||
self._last_brightness = self._attr_brightness
|
||||
self._light.set_brightness(0)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch new state data for this light."""
|
||||
if (brightness := self._light.get_brightness()) is not None:
|
||||
connected = self._light.connect()
|
||||
|
||||
try:
|
||||
brightness = self._light.get_brightness()
|
||||
rgb_color = self._light.get_rgb()
|
||||
finally:
|
||||
if connected:
|
||||
self._light.disconnect()
|
||||
|
||||
if brightness is not None:
|
||||
self._attr_is_on = brightness != 0
|
||||
self._attr_brightness = round(255 * (brightness / 4095))
|
||||
self._attr_brightness = _avea_brightness_to_ha(brightness)
|
||||
if self._attr_brightness:
|
||||
self._last_brightness = self._attr_brightness
|
||||
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb_color)
|
||||
|
||||
@@ -80,7 +80,8 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
def _color_temp_to_native_scale(self, x: int) -> int:
|
||||
"""Convert color temperature from Kelvin to native BleBox scale (0-255).
|
||||
|
||||
BleBox native scale is inverted relative to Kelvin: 0=warm (2700K), 255=cold (6500K).
|
||||
BleBox native scale is inverted:
|
||||
0=warm (2700K), 255=cold (6500K).
|
||||
"""
|
||||
scaled = (
|
||||
(self._attr_max_color_temp_kelvin - x)
|
||||
@@ -98,7 +99,8 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
def _color_temp_from_native_scale(self, x: int) -> int:
|
||||
"""Convert color temperature from native BleBox scale (0-255) to Kelvin.
|
||||
|
||||
BleBox native scale is inverted relative to Kelvin: 0=warm (2700K), 255=cold (6500K).
|
||||
BleBox native scale is inverted:
|
||||
0=warm (2700K), 255=cold (6500K).
|
||||
"""
|
||||
scaled = self._attr_max_color_temp_kelvin - (x / 255) * (
|
||||
self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin
|
||||
@@ -201,6 +203,10 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
else:
|
||||
value = feature.apply_brightness(value, brightness)
|
||||
|
||||
if isinstance(value, (list, tuple)) and not any(value):
|
||||
await self._feature.async_off()
|
||||
return
|
||||
|
||||
try:
|
||||
await self._feature.async_on(value)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -89,7 +89,9 @@ class PassiveBluetoothDataUpdateCoordinator(
|
||||
|
||||
|
||||
class PassiveBluetoothCoordinatorEntity[ # pylint: disable=home-assistant-enforce-class-module
|
||||
_PassiveBluetoothDataUpdateCoordinatorT: PassiveBluetoothDataUpdateCoordinator = PassiveBluetoothDataUpdateCoordinator
|
||||
_PassiveBluetoothDataUpdateCoordinatorT: (
|
||||
PassiveBluetoothDataUpdateCoordinator
|
||||
) = PassiveBluetoothDataUpdateCoordinator
|
||||
](BaseCoordinatorEntity[_PassiveBluetoothDataUpdateCoordinatorT]):
|
||||
"""A class for entities using DataUpdateCoordinator."""
|
||||
|
||||
|
||||
@@ -94,8 +94,8 @@ def serialize_service_info(
|
||||
"address": service_info.address,
|
||||
"rssi": service_info.rssi,
|
||||
"manufacturer_data": {
|
||||
str(manufacturer_id): manufacturer_data.hex()
|
||||
for manufacturer_id, manufacturer_data in service_info.manufacturer_data.items()
|
||||
str(manufacturer_id): data.hex()
|
||||
for manufacturer_id, data in service_info.manufacturer_data.items()
|
||||
},
|
||||
"service_data": {
|
||||
service_uuid: service_data.hex()
|
||||
|
||||
@@ -151,7 +151,9 @@ def sensor_update_to_bluetooth_data_update(
|
||||
device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[
|
||||
description.device_class
|
||||
]
|
||||
for device_key, description in sensor_update.binary_entity_descriptions.items()
|
||||
for device_key, description in (
|
||||
sensor_update.binary_entity_descriptions.items()
|
||||
)
|
||||
if description.device_class
|
||||
},
|
||||
entity_data={
|
||||
|
||||
@@ -52,14 +52,16 @@ async def async_get_calendars(
|
||||
warned_calendars.add((url, comp))
|
||||
if comp in ASSUMED_COMPONENTS:
|
||||
_LOGGER.warning(
|
||||
"CalDAV server does not report supported components for calendar %s, "
|
||||
"CalDAV server does not report supported"
|
||||
" components for calendar %s, "
|
||||
"assuming it supports the requested component '%s'",
|
||||
name or url,
|
||||
comp,
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"CalDAV server does not report supported components for calendar %s. "
|
||||
"CalDAV server does not report supported"
|
||||
" components for calendar %s. "
|
||||
"Not assuming support for requested component '%s'",
|
||||
name or url,
|
||||
comp,
|
||||
|
||||
@@ -31,7 +31,9 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Set up Cambridge Audio integration from a config entry."""
|
||||
|
||||
client = StreamMagicClient(entry.data[CONF_HOST], async_get_clientsession(hass))
|
||||
client = StreamMagicClient(
|
||||
entry.data[CONF_HOST], async_get_clientsession(hass), should_close_session=False
|
||||
)
|
||||
|
||||
async def _connection_update_callback(
|
||||
_client: StreamMagicClient, _callback_type: CallbackType
|
||||
|
||||
@@ -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.helpers import config_validation as cv
|
||||
from homeassistant.data_entry_flow import SectionConfig, section
|
||||
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
|
||||
|
||||
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
|
||||
CONF_MORE_OPTIONS = "more_options"
|
||||
KNOWN_HOSTS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
@@ -27,7 +27,19 @@ KNOWN_HOSTS_SCHEMA = vol.Schema(
|
||||
)
|
||||
}
|
||||
)
|
||||
WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
|
||||
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),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -92,100 +104,55 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
class CastOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Google Cast options."""
|
||||
|
||||
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(
|
||||
async def async_step_init(
|
||||
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, []))
|
||||
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()
|
||||
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.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=self.updated_config
|
||||
self.config_entry, data=updated_config
|
||||
)
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
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
|
||||
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]
|
||||
)
|
||||
|
||||
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="advanced_options",
|
||||
data_schema=vol.Schema(fields),
|
||||
errors=errors,
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, suggested),
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
|
||||
def _list_to_string(items):
|
||||
def _list_to_string(items: list[str]) -> str:
|
||||
comma_separated_string = ""
|
||||
if items:
|
||||
comma_separated_string = ",".join(items)
|
||||
return comma_separated_string
|
||||
|
||||
|
||||
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 _string_to_list(string: str) -> list[str]:
|
||||
return [x.strip() for x in string.split(",") if x.strip()]
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -718,9 +718,12 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
await self.hass.async_add_executor_job(
|
||||
self._quick_play, app_name, app_data
|
||||
)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except NotImplementedError:
|
||||
_LOGGER.error("App %s not supported", app_name)
|
||||
except NotImplementedError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="app_not_supported",
|
||||
translation_placeholders={"app_name": app_name},
|
||||
) from err
|
||||
return
|
||||
|
||||
# Try the cast platforms
|
||||
@@ -769,6 +772,8 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
media_id,
|
||||
err,
|
||||
)
|
||||
# Fallback: if playlist parsing fails, forward the raw URL to the device
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except PlaylistError as err:
|
||||
_LOGGER.warning(
|
||||
"[%s %s] Failed to parse playlist %s: %s",
|
||||
|
||||
@@ -18,26 +18,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"app_not_supported": {
|
||||
"message": "App {app_name} is not supported"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"invalid_known_hosts": "[%key:component::cast::config::error::invalid_known_hosts%]"
|
||||
},
|
||||
"step": {
|
||||
"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 don’t 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": {
|
||||
"init": {
|
||||
"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 don’t want to add all available cast devices."
|
||||
},
|
||||
"name": "More options"
|
||||
}
|
||||
},
|
||||
"title": "[%key:component::cast::config::step::config::title%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,8 +220,11 @@ class CloudClient(Interface):
|
||||
)
|
||||
if is_cloud_ice_servers_enabled:
|
||||
if self._cloud_ice_servers_listener is None:
|
||||
self._cloud_ice_servers_listener = await self.cloud.ice_servers.async_register_ice_servers_listener(
|
||||
register_cloud_ice_server
|
||||
ice_servers = self.cloud.ice_servers
|
||||
self._cloud_ice_servers_listener = (
|
||||
await ice_servers.async_register_ice_servers_listener(
|
||||
register_cloud_ice_server
|
||||
)
|
||||
)
|
||||
elif self._cloud_ice_servers_listener:
|
||||
self._cloud_ice_servers_listener()
|
||||
|
||||
@@ -94,13 +94,12 @@ class ComfoConnectFan(FanEntity):
|
||||
self._handle_mode_update,
|
||||
)
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE
|
||||
)
|
||||
# pylint: disable-next=home-assistant-sequential-executor-jobs
|
||||
await self.hass.async_add_executor_job(
|
||||
self._ccb.comfoconnect.register_sensor, SENSOR_OPERATING_MODE_BIS
|
||||
)
|
||||
|
||||
def _register_sensors() -> None:
|
||||
self._ccb.comfoconnect.register_sensor(SENSOR_FAN_SPEED_MODE)
|
||||
self._ccb.comfoconnect.register_sensor(SENSOR_OPERATING_MODE_BIS)
|
||||
|
||||
await self.hass.async_add_executor_job(_register_sensors)
|
||||
|
||||
def _handle_speed_update(self, value: float) -> None:
|
||||
"""Handle update callbacks."""
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -58,7 +59,7 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
|
||||
if not self.coordinator.data.week_plan:
|
||||
return None
|
||||
|
||||
today = date.today() # noqa: DTZ011
|
||||
today = dt_util.now().date()
|
||||
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 date, timedelta
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from cookidoo_api import (
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
|
||||
@@ -81,7 +82,9 @@ 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(date.today()) # noqa: DTZ011
|
||||
week_plan = await self.cookidoo.get_recipes_in_calendar_week(
|
||||
dt_util.now().date()
|
||||
)
|
||||
except CookidooAuthException:
|
||||
try:
|
||||
await self.cookidoo.refresh_token()
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"""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, DataGrandLyonCoordinator
|
||||
from .coordinator import (
|
||||
DataGrandLyonConfigEntry,
|
||||
DataGrandLyonData,
|
||||
DataGrandLyonTclCoordinator,
|
||||
DataGrandLyonVelovCoordinator,
|
||||
)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
@@ -22,10 +30,16 @@ async def async_setup_entry(
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
coordinator = DataGrandLyonCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client)
|
||||
velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
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.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."""
|
||||
coordinator = entry.runtime_data
|
||||
velov_coordinator = entry.runtime_data.velov_coordinator
|
||||
|
||||
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
|
||||
async_add_entities(
|
||||
(
|
||||
DataGrandLyonVelovBinarySensor(coordinator, subentry, description)
|
||||
DataGrandLyonVelovBinarySensor(velov_coordinator, subentry, description)
|
||||
for description in VELOV_BINARY_SENSOR_DESCRIPTIONS
|
||||
),
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
@@ -50,6 +50,5 @@ class DataGrandLyonVelovBinarySensor(DataGrandLyonVelovEntity, BinarySensorEntit
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the station is open."""
|
||||
return (
|
||||
self.coordinator.data.velov_stations[self._subentry_id].status
|
||||
== VelovStationStatus.OPEN
|
||||
self.coordinator.data[self._subentry_id].status == VelovStationStatus.OPEN
|
||||
)
|
||||
|
||||
@@ -28,19 +28,20 @@ from .const import (
|
||||
SUBENTRY_TYPE_VELOV_STATION,
|
||||
)
|
||||
|
||||
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataGrandLyonCoordinatorData:
|
||||
"""Data returned by the coordinator."""
|
||||
class DataGrandLyonData:
|
||||
"""Runtime data for the Data Grand Lyon integration."""
|
||||
|
||||
stops: dict[str, list[TclPassage]]
|
||||
velov_stations: dict[str, VelovStation]
|
||||
tcl_coordinator: DataGrandLyonTclCoordinator
|
||||
velov_coordinator: DataGrandLyonVelovCoordinator
|
||||
|
||||
|
||||
class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonCoordinatorData]):
|
||||
"""Coordinator for the Data Grand Lyon integration."""
|
||||
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData]
|
||||
|
||||
|
||||
class DataGrandLyonTclCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]):
|
||||
"""Coordinator for TCL transit passages."""
|
||||
|
||||
config_entry: DataGrandLyonConfigEntry
|
||||
|
||||
@@ -56,82 +57,112 @@ class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonCoordinatorDat
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
name=f"{DOMAIN}_tcl",
|
||||
update_interval=timedelta(minutes=5),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> DataGrandLyonCoordinatorData:
|
||||
"""Fetch data for all monitored stops and Vélo'v stations."""
|
||||
async def _async_update_data(self) -> dict[str, list[TclPassage]]:
|
||||
"""Fetch data for all monitored stops."""
|
||||
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 {}
|
||||
|
||||
has_stops = bool(stop_subentries)
|
||||
has_velov = bool(velov_subentries)
|
||||
stops: dict[str, list[TclPassage]] = {}
|
||||
velov_stations: dict[str, VelovStation] = {}
|
||||
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:
|
||||
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:
|
||||
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_all",
|
||||
)
|
||||
return DataGrandLyonCoordinatorData(stops=stops, velov_stations=velov_stations)
|
||||
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)
|
||||
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
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"Vélo'v station not found for subentry %s",
|
||||
subentry.subentry_id,
|
||||
)
|
||||
return velov_stations
|
||||
|
||||
@@ -16,18 +16,16 @@ 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 coordinator.data.stops.items()
|
||||
for subentry_id, passages in entry.runtime_data.tcl_coordinator.data.items()
|
||||
},
|
||||
"velov_stations": {
|
||||
subentry_id: asdict(station)
|
||||
for subentry_id, station in coordinator.data.velov_stations.items()
|
||||
for subentry_id, station in entry.runtime_data.velov_coordinator.data.items()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,20 +3,25 @@
|
||||
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
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DataGrandLyonCoordinator
|
||||
from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator
|
||||
|
||||
|
||||
class DataGrandLyonEntity(CoordinatorEntity[DataGrandLyonCoordinator]):
|
||||
class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator](
|
||||
CoordinatorEntity[_CoordinatorT]
|
||||
):
|
||||
"""Base entity for Data Grand Lyon."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataGrandLyonCoordinator,
|
||||
coordinator: _CoordinatorT,
|
||||
subentry: ConfigSubentry,
|
||||
description: EntityDescription,
|
||||
manufacturer: str,
|
||||
@@ -37,23 +42,33 @@ class DataGrandLyonEntity(CoordinatorEntity[DataGrandLyonCoordinator]):
|
||||
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 DataGrandLyonVelovEntity(DataGrandLyonEntity):
|
||||
|
||||
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]):
|
||||
"""Base entity for Data Grand Lyon Vélo'v stations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataGrandLyonCoordinator,
|
||||
coordinator: DataGrandLyonVelovCoordinator,
|
||||
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,14 +12,13 @@ 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, DataGrandLyonCoordinator
|
||||
from .entity import DataGrandLyonEntity, DataGrandLyonVelovEntity
|
||||
from .coordinator import DataGrandLyonConfigEntry
|
||||
from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -170,12 +169,13 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Data Grand Lyon sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
tcl_coordinator = entry.runtime_data.tcl_coordinator
|
||||
velov_coordinator = entry.runtime_data.velov_coordinator
|
||||
|
||||
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
|
||||
async_add_entities(
|
||||
(
|
||||
DataGrandLyonStopSensor(coordinator, subentry, description)
|
||||
DataGrandLyonStopSensor(tcl_coordinator, subentry, description)
|
||||
for description in STOP_SENSOR_DESCRIPTIONS
|
||||
),
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
@@ -184,41 +184,31 @@ async def async_setup_entry(
|
||||
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
|
||||
async_add_entities(
|
||||
(
|
||||
DataGrandLyonVelovSensor(coordinator, subentry, description)
|
||||
DataGrandLyonVelovSensor(velov_coordinator, subentry, description)
|
||||
for description in VELOV_SENSOR_DESCRIPTIONS
|
||||
),
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class DataGrandLyonStopSensor(DataGrandLyonEntity, SensorEntity):
|
||||
class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity):
|
||||
"""Sensor for Data Grand Lyon stop departures."""
|
||||
|
||||
entity_description: DataGrandLyonStopSensorEntityDescription
|
||||
|
||||
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 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]
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the sensor value."""
|
||||
departure = self._get_departure()
|
||||
if departure is None:
|
||||
return None
|
||||
departure = self.coordinator.data[self._subentry_id][
|
||||
self.entity_description.departure_index
|
||||
]
|
||||
return self.entity_description.value_fn(departure)
|
||||
|
||||
|
||||
@@ -227,18 +217,9 @@ 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.velov_stations[self._subentry_id]
|
||||
self.coordinator.data[self._subentry_id]
|
||||
)
|
||||
|
||||
@@ -158,11 +158,11 @@
|
||||
"auth_failed": {
|
||||
"message": "Authentication failed for Data Grand Lyon."
|
||||
},
|
||||
"update_failed_all": {
|
||||
"message": "[%key:component::data_grand_lyon::exceptions::update_failed_all_stops::message%]"
|
||||
"update_failed_tcl": {
|
||||
"message": "Error fetching TCL departures from Data Grand Lyon."
|
||||
},
|
||||
"update_failed_all_stops": {
|
||||
"message": "Error fetching Data Grand Lyon data: all requests failed."
|
||||
"update_failed_velov": {
|
||||
"message": "Error fetching Vélo'v stations from Data Grand Lyon."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,119 @@
|
||||
"""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 _LOGGER, HomeAssistant
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS
|
||||
from .const import (
|
||||
CONF_HOSTNAME,
|
||||
CONF_IPV4,
|
||||
CONF_IPV6,
|
||||
CONF_PORT_IPV6,
|
||||
CONF_RESOLVER,
|
||||
CONF_RESOLVER_IPV6,
|
||||
DEFAULT_PORT,
|
||||
PLATFORMS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@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:
|
||||
"""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: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool:
|
||||
"""Unload DNS IP config entry."""
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
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
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: DnsIPConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entry to a newer version."""
|
||||
|
||||
if config_entry.version > 1:
|
||||
|
||||
@@ -4,18 +4,18 @@ import asyncio
|
||||
from datetime import timedelta
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
import logging
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, 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: ConfigEntry,
|
||||
entry: DnsIPConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the dnsip sensor entry."""
|
||||
@@ -54,16 +54,29 @@ 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(name, hostname, nameserver_ipv4, False, port_ipv4))
|
||||
entities.append(
|
||||
WanIpSensor(
|
||||
entry,
|
||||
name,
|
||||
hostname,
|
||||
entry.options[CONF_RESOLVER],
|
||||
False,
|
||||
entry.options[CONF_PORT],
|
||||
)
|
||||
)
|
||||
if entry.data[CONF_IPV6]:
|
||||
entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
|
||||
entities.append(
|
||||
WanIpSensor(
|
||||
entry,
|
||||
name,
|
||||
hostname,
|
||||
entry.options[CONF_RESOLVER_IPV6],
|
||||
True,
|
||||
entry.options[CONF_PORT_IPV6],
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
@@ -75,10 +88,9 @@ 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,
|
||||
@@ -86,6 +98,8 @@ 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
|
||||
@@ -104,28 +118,43 @@ class WanIpSensor(SensorEntity):
|
||||
model=aiodns.__version__,
|
||||
name=name,
|
||||
)
|
||||
self.create_dns_resolver()
|
||||
|
||||
@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
|
||||
|
||||
def create_dns_resolver(self) -> None:
|
||||
"""Create the DNS resolver."""
|
||||
self.resolver = aiodns.DNSResolver(
|
||||
"""Create a new DNS resolver and store it on runtime data."""
|
||||
new_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(
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
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 ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError, 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
|
||||
@@ -29,8 +28,8 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
def download_file(service: ServiceCall) -> None:
|
||||
"""Start thread to download file specified in the URL."""
|
||||
async def download_file(service: ServiceCall) -> None:
|
||||
"""Download file specified in the URL."""
|
||||
|
||||
entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
|
||||
download_path = entry.data[CONF_DOWNLOAD_DIR]
|
||||
@@ -124,18 +123,7 @@ def download_file(service: ServiceCall) -> None:
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
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")
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
@@ -145,7 +133,28 @@ def download_file(service: ServiceCall) -> None:
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
|
||||
threading.Thread(target=do_download).start()
|
||||
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)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
}
|
||||
},
|
||||
"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}"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ from dsmr_parser.clients.rfxtrx_protocol import (
|
||||
create_rfxtrx_tcp_dsmr_reader,
|
||||
)
|
||||
from dsmr_parser.objects import DSMRObject
|
||||
import serial
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
@@ -117,7 +116,7 @@ class DSMRConnection:
|
||||
|
||||
try:
|
||||
transport, protocol = await asyncio.create_task(reader_factory())
|
||||
except serial.SerialException, OSError:
|
||||
except OSError:
|
||||
LOGGER.exception("Error connecting to DSMR")
|
||||
return False
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["dsmr_parser"],
|
||||
"requirements": ["dsmr-parser==1.5.0"]
|
||||
"requirements": ["dsmr-parser==1.7.0"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ from dsmr_parser.clients.rfxtrx_protocol import (
|
||||
create_rfxtrx_tcp_dsmr_reader,
|
||||
)
|
||||
from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram
|
||||
import serial
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
@@ -846,7 +845,7 @@ async def async_setup_entry(
|
||||
# throttle reconnect attempts
|
||||
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
|
||||
|
||||
except serial.SerialException, OSError:
|
||||
except OSError:
|
||||
# Log any error while establishing connection and drop to retry
|
||||
# connection wait
|
||||
LOGGER.exception("Error connecting to DSMR")
|
||||
|
||||
@@ -5,7 +5,14 @@ from enum import StrEnum
|
||||
from functools import partial
|
||||
from typing import Final
|
||||
|
||||
from easyenergy import Electricity, Gas, PriceInterval, VatOption
|
||||
from easyenergy import (
|
||||
Electricity,
|
||||
ElectricityGranularity,
|
||||
ElectricityPriceType,
|
||||
Gas,
|
||||
PriceInterval,
|
||||
VatOption,
|
||||
)
|
||||
from easyenergy.const import MARKET_TIMEZONE
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -27,29 +34,15 @@ ATTR_CONFIG_ENTRY: Final = "config_entry"
|
||||
ATTR_START: Final = "start"
|
||||
ATTR_END: Final = "end"
|
||||
ATTR_INCL_VAT: Final = "incl_vat"
|
||||
ATTR_GRANULARITY: Final = "granularity"
|
||||
ATTR_PRICE_TYPE: Final = "price_type"
|
||||
|
||||
GAS_SERVICE_NAME: Final = "get_gas_prices"
|
||||
ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices"
|
||||
ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices"
|
||||
BASE_SERVICE_SCHEMA: Final = {
|
||||
vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
|
||||
{
|
||||
"integration": DOMAIN,
|
||||
}
|
||||
),
|
||||
vol.Optional(ATTR_START): str,
|
||||
vol.Optional(ATTR_END): str,
|
||||
}
|
||||
SERVICE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
**BASE_SERVICE_SCHEMA,
|
||||
vol.Required(ATTR_INCL_VAT): bool,
|
||||
}
|
||||
)
|
||||
RETURN_SERVICE_SCHEMA: Final = vol.Schema(BASE_SERVICE_SCHEMA)
|
||||
|
||||
|
||||
class PriceType(StrEnum):
|
||||
class ServicePriceType(StrEnum):
|
||||
"""Type of price."""
|
||||
|
||||
ENERGY_USAGE = "energy_usage"
|
||||
@@ -57,6 +50,52 @@ class PriceType(StrEnum):
|
||||
GAS = "gas"
|
||||
|
||||
|
||||
GRANULARITY_OPTIONS: Final = tuple(
|
||||
granularity.value for granularity in ElectricityGranularity
|
||||
)
|
||||
PRICE_TYPE_OPTIONS: Final = tuple(
|
||||
electricity_price_type.value for electricity_price_type in ElectricityPriceType
|
||||
)
|
||||
|
||||
BASE_SERVICE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
|
||||
{
|
||||
"integration": DOMAIN,
|
||||
}
|
||||
),
|
||||
vol.Optional(ATTR_START): str,
|
||||
vol.Optional(ATTR_END): str,
|
||||
}
|
||||
)
|
||||
GAS_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_INCL_VAT): bool,
|
||||
vol.Optional(
|
||||
ATTR_PRICE_TYPE, default=ElectricityPriceType.MARKET.value
|
||||
): vol.In(PRICE_TYPE_OPTIONS),
|
||||
}
|
||||
)
|
||||
ENERGY_USAGE_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_INCL_VAT): bool,
|
||||
vol.Optional(
|
||||
ATTR_GRANULARITY, default=ElectricityGranularity.HOUR.value
|
||||
): vol.In(GRANULARITY_OPTIONS),
|
||||
vol.Optional(
|
||||
ATTR_PRICE_TYPE, default=ElectricityPriceType.MARKET.value
|
||||
): vol.In(PRICE_TYPE_OPTIONS),
|
||||
}
|
||||
)
|
||||
ENERGY_RETURN_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(
|
||||
ATTR_GRANULARITY, default=ElectricityGranularity.HOUR.value
|
||||
): vol.In(GRANULARITY_OPTIONS),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def __get_date(
|
||||
date_input: str | None,
|
||||
) -> tuple[date, datetime | None]:
|
||||
@@ -113,6 +152,19 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp
|
||||
}
|
||||
|
||||
|
||||
def __select_prices(
|
||||
data: Electricity | Gas, use_invoice: bool
|
||||
) -> list[dict[str, float | datetime]]:
|
||||
"""Select market or invoice prices from price data."""
|
||||
if not use_invoice:
|
||||
return data.timestamp_prices
|
||||
|
||||
return [
|
||||
{"timestamp": interval.starts_at, "price": interval.invoice_price}
|
||||
for interval in data.intervals
|
||||
]
|
||||
|
||||
|
||||
def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator:
|
||||
"""Get the coordinator from the entry."""
|
||||
entry: EasyEnergyConfigEntry = service.async_get_config_entry(
|
||||
@@ -124,7 +176,7 @@ def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator:
|
||||
async def __get_prices(
|
||||
call: ServiceCall,
|
||||
*,
|
||||
price_type: PriceType,
|
||||
service_price_type: ServicePriceType,
|
||||
) -> ServiceResponse:
|
||||
"""Get prices from easyEnergy."""
|
||||
coordinator = __get_coordinator(call)
|
||||
@@ -137,23 +189,29 @@ async def __get_prices(
|
||||
vat = VatOption.EXCLUDE
|
||||
|
||||
data: Electricity | Gas
|
||||
prices: list[dict[str, float | datetime]]
|
||||
|
||||
if price_type == PriceType.GAS:
|
||||
if service_price_type == ServicePriceType.GAS:
|
||||
data = await coordinator.easyenergy.gas_prices(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
vat=vat,
|
||||
)
|
||||
prices = data.timestamp_prices
|
||||
prices = __select_prices(
|
||||
data, call.data[ATTR_PRICE_TYPE] == ElectricityPriceType.INVOICE.value
|
||||
)
|
||||
else:
|
||||
data = await coordinator.easyenergy.energy_prices(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
granularity=ElectricityGranularity(call.data[ATTR_GRANULARITY]),
|
||||
vat=vat,
|
||||
)
|
||||
|
||||
if price_type == PriceType.ENERGY_USAGE:
|
||||
prices = data.timestamp_prices
|
||||
if service_price_type == ServicePriceType.ENERGY_USAGE:
|
||||
prices = __select_prices(
|
||||
data, call.data[ATTR_PRICE_TYPE] == ElectricityPriceType.INVOICE.value
|
||||
)
|
||||
else:
|
||||
prices = data.timestamp_return_prices
|
||||
|
||||
@@ -181,21 +239,21 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
GAS_SERVICE_NAME,
|
||||
partial(__get_prices, price_type=PriceType.GAS),
|
||||
schema=SERVICE_SCHEMA,
|
||||
partial(__get_prices, service_price_type=ServicePriceType.GAS),
|
||||
schema=GAS_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
ENERGY_USAGE_SERVICE_NAME,
|
||||
partial(__get_prices, price_type=PriceType.ENERGY_USAGE),
|
||||
schema=SERVICE_SCHEMA,
|
||||
partial(__get_prices, service_price_type=ServicePriceType.ENERGY_USAGE),
|
||||
schema=ENERGY_USAGE_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
ENERGY_RETURN_SERVICE_NAME,
|
||||
partial(__get_prices, price_type=PriceType.ENERGY_RETURN),
|
||||
schema=RETURN_SERVICE_SCHEMA,
|
||||
partial(__get_prices, service_price_type=ServicePriceType.ENERGY_RETURN),
|
||||
schema=ENERGY_RETURN_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,15 @@ get_gas_prices:
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
price_type:
|
||||
required: false
|
||||
default: market
|
||||
selector:
|
||||
select:
|
||||
translation_key: price_type_selector
|
||||
options:
|
||||
- market
|
||||
- invoice
|
||||
start:
|
||||
required: false
|
||||
example: "2024-01-01 00:00:00"
|
||||
@@ -32,6 +41,24 @@ get_energy_usage_prices:
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
granularity:
|
||||
required: false
|
||||
default: hour
|
||||
selector:
|
||||
select:
|
||||
translation_key: granularity_selector
|
||||
options:
|
||||
- hour
|
||||
- quarter
|
||||
price_type:
|
||||
required: false
|
||||
default: market
|
||||
selector:
|
||||
select:
|
||||
translation_key: price_type_selector
|
||||
options:
|
||||
- market
|
||||
- invoice
|
||||
start:
|
||||
required: false
|
||||
example: "2024-01-01 00:00:00"
|
||||
@@ -49,6 +76,15 @@ get_energy_return_prices:
|
||||
selector:
|
||||
config_entry:
|
||||
integration: easyenergy
|
||||
granularity:
|
||||
required: false
|
||||
default: hour
|
||||
selector:
|
||||
select:
|
||||
translation_key: granularity_selector
|
||||
options:
|
||||
- hour
|
||||
- quarter
|
||||
start:
|
||||
required: false
|
||||
example: "2024-01-01 00:00:00"
|
||||
|
||||
@@ -54,6 +54,20 @@
|
||||
"message": "Invalid date provided. Got {date}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"granularity_selector": {
|
||||
"options": {
|
||||
"hour": "Hour",
|
||||
"quarter": "Quarter"
|
||||
}
|
||||
},
|
||||
"price_type_selector": {
|
||||
"options": {
|
||||
"invoice": "All-in price",
|
||||
"market": "Market price"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_energy_return_prices": {
|
||||
"description": "Requests return energy prices from easyEnergy.",
|
||||
@@ -66,6 +80,10 @@
|
||||
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]",
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]"
|
||||
},
|
||||
"granularity": {
|
||||
"description": "[%key:component::easyenergy::services::get_energy_usage_prices::fields::granularity::description%]",
|
||||
"name": "[%key:component::easyenergy::services::get_energy_usage_prices::fields::granularity::name%]"
|
||||
},
|
||||
"start": {
|
||||
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]",
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]"
|
||||
@@ -84,10 +102,18 @@
|
||||
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]",
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]"
|
||||
},
|
||||
"granularity": {
|
||||
"description": "The interval size for the electricity prices.",
|
||||
"name": "Granularity"
|
||||
},
|
||||
"incl_vat": {
|
||||
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::description%]",
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::name%]"
|
||||
},
|
||||
"price_type": {
|
||||
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::price_type::description%]",
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::price_type::name%]"
|
||||
},
|
||||
"start": {
|
||||
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]",
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]"
|
||||
@@ -110,6 +136,10 @@
|
||||
"description": "Whether the prices should include VAT.",
|
||||
"name": "VAT included"
|
||||
},
|
||||
"price_type": {
|
||||
"description": "The type of prices to retrieve.",
|
||||
"name": "Price type"
|
||||
},
|
||||
"start": {
|
||||
"description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted.",
|
||||
"name": "Start"
|
||||
|
||||
@@ -47,6 +47,23 @@ 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."""
|
||||
|
||||
@@ -60,19 +77,7 @@ class ElkEntity(Entity):
|
||||
self._mac = elk_data.mac
|
||||
self._prefix = elk_data.prefix
|
||||
self._temperature_unit: str = elk_data.config["temperature_unit"]
|
||||
# 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._unique_id = generate_unique_id(self._prefix, element)
|
||||
self._attr_name = element.name
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for control of ElkM1 sensors."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from elkm1_lib.const import SettingFormat, ZoneType
|
||||
from elkm1_lib.counters import Counter
|
||||
@@ -20,13 +20,19 @@ 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
|
||||
from homeassistant.helpers import entity_platform, entity_registry as er
|
||||
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
|
||||
from .entity import (
|
||||
ElkAttachedEntity,
|
||||
ElkEntity,
|
||||
create_elk_entities,
|
||||
generate_unique_id,
|
||||
)
|
||||
from .util import deprecate_entity
|
||||
|
||||
SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh"
|
||||
SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set"
|
||||
@@ -58,11 +64,37 @@ 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,6 +58,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""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
|
||||
@@ -138,6 +138,9 @@ 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."""
|
||||
@@ -148,6 +151,9 @@ 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."""
|
||||
@@ -166,6 +172,9 @@ 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."""
|
||||
@@ -464,6 +473,7 @@ 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"),
|
||||
@@ -483,6 +493,7 @@ 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(
|
||||
@@ -495,6 +506,7 @@ 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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ PLATFORMS = [
|
||||
|
||||
INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired)
|
||||
|
||||
SETUP_RETRY_TIMEOUT = 50
|
||||
OPERATIONAL_RETRY_TIMEOUT = 200
|
||||
|
||||
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES = "diagnostics_include_fixtures"
|
||||
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES_DEFAULT_VALUE = False
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN, INVALID_AUTH_ERRORS
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
INVALID_AUTH_ERRORS,
|
||||
OPERATIONAL_RETRY_TIMEOUT,
|
||||
SETUP_RETRY_TIMEOUT,
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
@@ -50,6 +55,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.username = entry_data[CONF_USERNAME]
|
||||
self.password = entry_data[CONF_PASSWORD]
|
||||
self._setup_complete = False
|
||||
self._operational_timeout = False
|
||||
self.envoy_firmware = ""
|
||||
self.interface = None
|
||||
self._cancel_token_refresh: CALLBACK_TYPE | None = None
|
||||
@@ -265,10 +271,15 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
if not self._setup_complete:
|
||||
_LOGGER.debug("update on try %s, setup not complete", tries)
|
||||
self.envoy.set_retry_policy(max_delay=SETUP_RETRY_TIMEOUT)
|
||||
self._operational_timeout = False
|
||||
await self._async_setup_and_authenticate()
|
||||
self._async_mark_setup_complete()
|
||||
# dump all received data in debug mode to assist troubleshooting
|
||||
envoy_data = await envoy.update()
|
||||
if not self._operational_timeout:
|
||||
self.envoy.set_retry_policy(max_delay=OPERATIONAL_RETRY_TIMEOUT)
|
||||
self._operational_timeout = True
|
||||
except INVALID_AUTH_ERRORS as err:
|
||||
_LOGGER.debug("update on try %s, INVALID_AUTH_ERRORS %s", tries, err)
|
||||
if self._setup_complete and tries == 0:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyenvisalink"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyenvisalink==4.7"]
|
||||
"requirements": ["pyenvisalink==4.9"]
|
||||
}
|
||||
|
||||
@@ -122,19 +122,15 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout)
|
||||
|
||||
# We need to wake hibernating cameras.
|
||||
# First create EZVIZ API instance.
|
||||
await self.hass.async_add_executor_job(ezviz_client.login)
|
||||
def _login_wake_and_test() -> None:
|
||||
# Login to create EZVIZ API instance.
|
||||
ezviz_client.login()
|
||||
# Wake hibernating camera.
|
||||
ezviz_client.get_detection_sensibility(data[ATTR_SERIAL])
|
||||
# Attempt an authenticated RTSP DESCRIBE request.
|
||||
_test_camera_rtsp_creds(data)
|
||||
|
||||
# Secondly try to wake hybernating camera.
|
||||
# pylint: disable-next=home-assistant-sequential-executor-jobs
|
||||
await self.hass.async_add_executor_job(
|
||||
ezviz_client.get_detection_sensibility, data[ATTR_SERIAL]
|
||||
)
|
||||
|
||||
# Thirdly attempts an authenticated RTSP DESCRIBE request.
|
||||
# pylint: disable-next=home-assistant-sequential-executor-jobs
|
||||
await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data)
|
||||
await self.hass.async_add_executor_job(_login_wake_and_test)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=data[ATTR_SERIAL],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from fjaraskupan import Device
|
||||
from fjaraskupan import UUID_SERVICE, Device
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothCallbackMatcher,
|
||||
@@ -37,6 +37,7 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_UUID = str(UUID_SERVICE).lower()
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry) -> bool:
|
||||
@@ -44,39 +45,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry)
|
||||
|
||||
entry.runtime_data = {}
|
||||
|
||||
def detection_callback(
|
||||
service_info: BluetoothServiceInfoBleak, change: BluetoothChange
|
||||
def data_callback(
|
||||
service_info: BluetoothServiceInfoBleak, change_: BluetoothChange
|
||||
) -> None:
|
||||
if change != BluetoothChange.ADVERTISEMENT:
|
||||
if (data := entry.runtime_data.get(service_info.address)) is None:
|
||||
_LOGGER.debug("Ignoring: %s", service_info)
|
||||
return
|
||||
if data := entry.runtime_data.get(service_info.address):
|
||||
_LOGGER.debug("Update: %s", service_info)
|
||||
data.detection_callback(service_info)
|
||||
else:
|
||||
_LOGGER.debug("Detected: %s", service_info)
|
||||
|
||||
device = Device(service_info.device.address)
|
||||
device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_BLUETOOTH, service_info.address)},
|
||||
identifiers={(DOMAIN, service_info.address)},
|
||||
manufacturer="Fjäråskupan",
|
||||
name="Fjäråskupan",
|
||||
)
|
||||
_LOGGER.debug("Update: %s", service_info)
|
||||
data.detection_callback(service_info)
|
||||
|
||||
coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator(
|
||||
hass, entry, device, device_info
|
||||
)
|
||||
coordinator.detection_callback(service_info)
|
||||
def detect_callback(
|
||||
service_info: BluetoothServiceInfoBleak, change_: BluetoothChange
|
||||
) -> None:
|
||||
if service_info.address in entry.runtime_data:
|
||||
return
|
||||
|
||||
entry.runtime_data[service_info.address] = coordinator
|
||||
async_dispatcher_send(
|
||||
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
|
||||
)
|
||||
_LOGGER.debug("Detected: %s", service_info)
|
||||
device = Device(service_info.device.address)
|
||||
device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_BLUETOOTH, service_info.address)},
|
||||
identifiers={(DOMAIN, service_info.address)},
|
||||
manufacturer="Fjäråskupan",
|
||||
name="Fjäråskupan",
|
||||
)
|
||||
|
||||
coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator(
|
||||
hass, entry, device, device_info
|
||||
)
|
||||
coordinator.detection_callback(service_info)
|
||||
|
||||
entry.runtime_data[service_info.address] = coordinator
|
||||
async_dispatcher_send(
|
||||
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_register_callback(
|
||||
hass,
|
||||
detection_callback,
|
||||
data_callback,
|
||||
BluetoothCallbackMatcher(
|
||||
manufacturer_id=20296,
|
||||
manufacturer_data_start=[79, 68, 70, 74, 65, 82],
|
||||
@@ -86,6 +93,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry)
|
||||
)
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_register_callback(
|
||||
hass,
|
||||
detect_callback,
|
||||
BluetoothCallbackMatcher(
|
||||
service_uuid=_UUID,
|
||||
connectable=False,
|
||||
),
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Config flow for Fjäråskupan integration."""
|
||||
|
||||
from fjaraskupan import device_filter
|
||||
from fjaraskupan import UUID_SERVICE
|
||||
|
||||
from homeassistant.components.bluetooth import async_discovered_service_info
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -15,7 +15,8 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
service_infos = async_discovered_service_info(hass)
|
||||
|
||||
for service_info in service_infos:
|
||||
if device_filter(service_info.device, service_info.advertisement):
|
||||
uuids = service_info.service_uuids
|
||||
if str(UUID_SERVICE) in uuids:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"bluetooth": [
|
||||
{
|
||||
"connectable": false,
|
||||
"manufacturer_data_start": [79, 68, 70, 74, 65, 82],
|
||||
"manufacturer_id": 20296
|
||||
"service_uuid": "77a2bd49-1e5a-4961-bba1-21f34fa4bc7b"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@elupus"],
|
||||
|
||||
@@ -468,9 +468,11 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
return self._player["volume"] == 0
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Content ID of current playing media."""
|
||||
return self._player["item_id"]
|
||||
if (item_id := self._player["item_id"]) == 0:
|
||||
return None
|
||||
return str(item_id)
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Device tracker platform for fressnapf_tracker."""
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType
|
||||
from homeassistant.components.device_tracker.config_entry import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -61,11 +60,6 @@ class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
|
||||
return self.coordinator.data.position.lng
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type, eg gps or router, of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device.
|
||||
|
||||
@@ -54,6 +54,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
|
||||
),
|
||||
)
|
||||
|
||||
hass.data.setdefault(FRITZ_DATA_KEY, FritzData())
|
||||
|
||||
try:
|
||||
await avm_wrapper.async_setup(entry.options)
|
||||
except FRITZ_AUTH_EXCEPTIONS as ex:
|
||||
@@ -68,13 +70,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
|
||||
raise ConfigEntryAuthFailed("Missing UPnP configuration")
|
||||
|
||||
await avm_wrapper.async_config_entry_first_refresh()
|
||||
await avm_wrapper.async_trigger_cleanup()
|
||||
|
||||
entry.runtime_data = avm_wrapper
|
||||
|
||||
if FRITZ_DATA_KEY not in hass.data:
|
||||
hass.data[FRITZ_DATA_KEY] = FritzData()
|
||||
|
||||
# Load the other platforms like switch
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -89,6 +87,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo
|
||||
|
||||
if avm_wrapper.unique_id in fritz_data.tracked:
|
||||
fritz_data.tracked.pop(avm_wrapper.unique_id)
|
||||
fritz_data.profile_switches.pop(avm_wrapper.unique_id)
|
||||
fritz_data.wol_buttons.pop(avm_wrapper.unique_id)
|
||||
|
||||
if not bool(fritz_data.tracked):
|
||||
hass.data.pop(FRITZ_DATA_KEY)
|
||||
|
||||
@@ -217,9 +217,6 @@ def _async_wol_buttons_list(
|
||||
|
||||
new_wols: list[FritzBoxWOLButton] = []
|
||||
|
||||
if avm_wrapper.unique_id not in data_fritz.wol_buttons:
|
||||
data_fritz.wol_buttons[avm_wrapper.unique_id] = set()
|
||||
|
||||
for mac, device in avm_wrapper.devices.items():
|
||||
if _is_tracked(mac, data_fritz.wol_buttons.values()):
|
||||
_LOGGER.debug("Skipping wol button creation for device %s", device.hostname)
|
||||
|
||||
@@ -187,6 +187,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self._options = options
|
||||
await self.hass.async_add_executor_job(self.setup)
|
||||
|
||||
self.hass.data[FRITZ_DATA_KEY].tracked[self.unique_id] = set()
|
||||
self.hass.data[FRITZ_DATA_KEY].profile_switches[self.unique_id] = set()
|
||||
self.hass.data[FRITZ_DATA_KEY].wol_buttons[self.unique_id] = set()
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
@@ -715,6 +719,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
) and entry_mac not in device_hosts:
|
||||
_LOGGER.debug("Removing orphan entity entry %s", entity.entity_id)
|
||||
entity_reg.async_remove(entity.entity_id)
|
||||
self._devices.pop(entry_mac, None)
|
||||
|
||||
device_reg = dr.async_get(self.hass)
|
||||
valid_connections = {
|
||||
@@ -729,6 +734,29 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
|
||||
fritz_data = self.hass.data[FRITZ_DATA_KEY]
|
||||
|
||||
tracked = fritz_data.tracked.get(self.unique_id, set())
|
||||
for mac in tracked.copy():
|
||||
if mac in device_hosts:
|
||||
continue
|
||||
_LOGGER.debug("Removing orphan mac address %s from device trackers", mac)
|
||||
tracked.remove(mac)
|
||||
|
||||
profile_switches = fritz_data.profile_switches.get(self.unique_id, set())
|
||||
for mac in profile_switches.copy():
|
||||
if mac in device_hosts:
|
||||
continue
|
||||
_LOGGER.debug("Removing orphan mac address %s from profile switches", mac)
|
||||
profile_switches.remove(mac)
|
||||
|
||||
wol_buttons = fritz_data.wol_buttons.get(self.unique_id, set())
|
||||
for mac in wol_buttons.copy():
|
||||
if mac in device_hosts:
|
||||
continue
|
||||
_LOGGER.debug("Removing orphan mac address %s from WOL buttons", mac)
|
||||
wol_buttons.remove(mac)
|
||||
|
||||
|
||||
class AvmWrapper(FritzBoxTools):
|
||||
"""Setup AVM wrapper for API calls."""
|
||||
|
||||
@@ -51,9 +51,6 @@ def _async_add_entities(
|
||||
"""Add new tracker entities from the AVM device."""
|
||||
|
||||
new_tracked = []
|
||||
if avm_wrapper.unique_id not in data_fritz.tracked:
|
||||
data_fritz.tracked[avm_wrapper.unique_id] = set()
|
||||
|
||||
for mac, device in avm_wrapper.devices.items():
|
||||
if device_filter_out_from_trackers(mac, device, data_fritz.tracked.values()):
|
||||
continue
|
||||
|
||||
@@ -242,9 +242,6 @@ async def _async_profile_entities_list(
|
||||
if "X_AVM-DE_HostFilter1" not in avm_wrapper.connection.services:
|
||||
return new_profiles
|
||||
|
||||
if avm_wrapper.unique_id not in data_fritz.profile_switches:
|
||||
data_fritz.profile_switches[avm_wrapper.unique_id] = set()
|
||||
|
||||
for mac, device in avm_wrapper.devices.items():
|
||||
if device_filter_out_from_trackers(
|
||||
mac, device, data_fritz.profile_switches.values()
|
||||
|
||||
@@ -6,6 +6,5 @@ CONF_DEBUG_UI = "debug_ui"
|
||||
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||
HA_MANAGED_API_PORT = 11984
|
||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
|
||||
# in script/hassfest/docker.py.
|
||||
# Kept in sync with the go2rtc image pinned in the root Dockerfile by Renovate.
|
||||
RECOMMENDED_VERSION = "1.9.14"
|
||||
|
||||
@@ -57,7 +57,9 @@ def sensor_update_to_bluetooth_data_update(
|
||||
device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[
|
||||
description.device_class
|
||||
]
|
||||
for device_key, description in sensor_update.binary_entity_descriptions.items()
|
||||
for device_key, description in (
|
||||
sensor_update.binary_entity_descriptions.items()
|
||||
)
|
||||
if description.device_class
|
||||
},
|
||||
entity_data={
|
||||
|
||||
@@ -36,7 +36,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"data": {
|
||||
"valve_controller": {
|
||||
api_category: async_redact_data(coordinator.data, TO_REDACT)
|
||||
for api_category, coordinator in data.valve_controller_coordinators.items()
|
||||
for api_category, coordinator in (
|
||||
data.valve_controller_coordinators.items()
|
||||
)
|
||||
},
|
||||
"paired_sensors": [
|
||||
async_redact_data(coordinator.data, TO_REDACT)
|
||||
|
||||
@@ -21,6 +21,13 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_FAN_SPEED_PERCENTAGE_KEY = (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
)
|
||||
_FAN_SPEED_MODE_KEY = (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
|
||||
FAN_SPEED_MODE_OPTIONS = {
|
||||
"auto": (
|
||||
"HeatingVentilationAirConditioning"
|
||||
@@ -116,8 +123,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the speed percentage and speed mode values."""
|
||||
option_value = None
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
if event := self.appliance.events.get(EventKey(option_key)):
|
||||
if event := self.appliance.events.get(EventKey(_FAN_SPEED_PERCENTAGE_KEY)):
|
||||
option_value = event.value
|
||||
self._attr_percentage = (
|
||||
cast(int, option_value) if option_value is not None else None
|
||||
@@ -142,8 +148,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
def update_preset_mode(self) -> None:
|
||||
"""Set the preset mode value."""
|
||||
option_value = None
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
if event := self.appliance.events.get(EventKey(option_key)):
|
||||
if event := self.appliance.events.get(EventKey(_FAN_SPEED_MODE_KEY)):
|
||||
option_value = event.value
|
||||
self._attr_preset_mode = (
|
||||
FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
|
||||
@@ -133,7 +133,9 @@ SELECT_ENTITY_DESCRIPTIONS = (
|
||||
translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items()
|
||||
for translation_key, value in (
|
||||
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items()
|
||||
)
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
import httpx
|
||||
from iaqualink.exception import (
|
||||
AqualinkServiceException,
|
||||
AqualinkServiceThrottledException,
|
||||
AqualinkServiceUnauthorizedException,
|
||||
)
|
||||
|
||||
@@ -44,6 +45,12 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
await self.system.update()
|
||||
except AqualinkServiceUnauthorizedException as err:
|
||||
raise ConfigEntryAuthFailed("Invalid credentials for iAquaLink") from err
|
||||
except AqualinkServiceThrottledException:
|
||||
_LOGGER.warning(
|
||||
"Rate limited by iAquaLink system %s, will retry later",
|
||||
self.system.serial,
|
||||
)
|
||||
return
|
||||
except (AqualinkServiceException, httpx.HTTPError) as err:
|
||||
raise UpdateFailed(
|
||||
f"Unable to update iAquaLink system {self.system.serial}: {err}"
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioimmich"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioimmich==0.14.0"]
|
||||
"requirements": ["aioimmich==0.14.1"]
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltSolar.DC_INPUT_POWER_3,
|
||||
IndevoltSolar.DC_INPUT_POWER_4,
|
||||
IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||
IndevoltConfig.READ_REALTIME_COMMAND,
|
||||
IndevoltConfig.READ_REALTIME_TARGET_SOC,
|
||||
IndevoltConfig.READ_REALTIME_POWER_LIMIT,
|
||||
IndevoltGrid.METER_POWER_GEN1,
|
||||
IndevoltGrid.METER_CONNECTED,
|
||||
IndevoltSolar.CUMULATIVE_PRODUCTION,
|
||||
@@ -137,6 +140,9 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltConfig.READ_INVERTER_INPUT_LIMIT,
|
||||
IndevoltConfig.READ_FEEDIN_POWER_LIMIT,
|
||||
IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||
IndevoltConfig.READ_REALTIME_COMMAND,
|
||||
IndevoltConfig.READ_REALTIME_TARGET_SOC,
|
||||
IndevoltConfig.READ_REALTIME_POWER_LIMIT,
|
||||
IndevoltBattery.MAIN_HEATING_STATE,
|
||||
IndevoltBattery.PACK_1_HEATING_STATE,
|
||||
IndevoltBattery.PACK_2_HEATING_STATE,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Final, cast
|
||||
from indevolt_api import (
|
||||
IndevoltBattery,
|
||||
IndevoltConfig,
|
||||
IndevoltEnergyMode,
|
||||
IndevoltGrid,
|
||||
IndevoltSolar,
|
||||
IndevoltSystem,
|
||||
@@ -43,6 +44,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
state_mapping: dict[str | int, str] = field(default_factory=dict)
|
||||
generation: tuple[int, ...] = (1, 2)
|
||||
energy_mode: IndevoltEnergyMode | None = None
|
||||
|
||||
|
||||
SENSORS: Final = (
|
||||
@@ -84,6 +86,28 @@ SENSORS: Final = (
|
||||
translation_key="discharge_limit",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
# Real-time control state
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltConfig.READ_REALTIME_COMMAND,
|
||||
translation_key="realtime_command",
|
||||
state_mapping={1000: "standby", 1001: "charging", 1002: "discharging"},
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltConfig.READ_REALTIME_TARGET_SOC,
|
||||
translation_key="realtime_target_soc",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltConfig.READ_REALTIME_POWER_LIMIT,
|
||||
translation_key="realtime_power_limit",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSystem.INPUT_POWER,
|
||||
translation_key="ac_input_power",
|
||||
@@ -851,6 +875,16 @@ class IndevoltSensorEntity(IndevoltEntity, SensorEntity):
|
||||
if description.device_class == SensorDeviceClass.ENUM:
|
||||
self._attr_options = sorted(set(description.state_mapping.values()))
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return False when the device is not in the required energy mode."""
|
||||
if self.entity_description.energy_mode is not None:
|
||||
energy_mode = self.coordinator.data.get(IndevoltConfig.READ_ENERGY_MODE)
|
||||
if energy_mode != self.entity_description.energy_mode:
|
||||
return False
|
||||
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | None:
|
||||
"""Return the current value of the sensor in its native unit."""
|
||||
|
||||
@@ -326,6 +326,20 @@
|
||||
"rated_capacity": {
|
||||
"name": "Rated capacity"
|
||||
},
|
||||
"realtime_command": {
|
||||
"name": "Real-time mode",
|
||||
"state": {
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"discharging": "[%key:common::state::discharging%]",
|
||||
"standby": "[%key:common::state::standby%]"
|
||||
}
|
||||
},
|
||||
"realtime_power_limit": {
|
||||
"name": "Real-time power limit"
|
||||
},
|
||||
"realtime_target_soc": {
|
||||
"name": "Real-time target SOC"
|
||||
},
|
||||
"serial_number": {
|
||||
"name": "Serial number"
|
||||
},
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pyintesishome"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyintesishome==1.8.7"]
|
||||
"requirements": ["pyintesishome==1.8.8"]
|
||||
}
|
||||
|
||||
@@ -280,7 +280,9 @@ class KNXModule:
|
||||
or next(
|
||||
(
|
||||
_transcoder
|
||||
for _filter, _transcoder in self._address_filter_transcoder.items()
|
||||
for _filter, _transcoder in (
|
||||
self._address_filter_transcoder.items()
|
||||
)
|
||||
if _filter.match(telegram.destination_address)
|
||||
),
|
||||
None,
|
||||
|
||||
@@ -114,21 +114,27 @@ async def service_event_register_modify(call: ServiceCall) -> None:
|
||||
knx_module = get_knx_module(call.hass)
|
||||
|
||||
attr_address = call.data[KNX_ADDRESS]
|
||||
group_addresses = list(map(parse_device_group_address, attr_address))
|
||||
group_addresses = set(map(parse_device_group_address, attr_address))
|
||||
|
||||
if call.data.get(SERVICE_KNX_ATTR_REMOVE):
|
||||
_error_gas = set()
|
||||
for group_address in group_addresses:
|
||||
try:
|
||||
knx_module.knx_event_callback.group_addresses.remove(group_address)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Service event_register could not remove event for '%s'",
|
||||
str(group_address),
|
||||
)
|
||||
_error_gas.add(group_address)
|
||||
if group_address in knx_module.group_address_transcoder:
|
||||
del knx_module.group_address_transcoder[group_address]
|
||||
return
|
||||
|
||||
if not _error_gas:
|
||||
return
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_event_register_ga_not_found",
|
||||
translation_placeholders={
|
||||
"group_addresses": ", ".join(map(str, sorted(_error_gas)))
|
||||
},
|
||||
)
|
||||
|
||||
if (dpt := call.data.get(CONF_TYPE)) and (
|
||||
transcoder := DPTBase.parse_transcoder(dpt)
|
||||
|
||||
@@ -1094,6 +1094,9 @@
|
||||
"integration_not_loaded": {
|
||||
"message": "KNX integration is not loaded."
|
||||
},
|
||||
"service_event_register_ga_not_found": {
|
||||
"message": "Could not find registered event for `{group_addresses}` to remove."
|
||||
},
|
||||
"service_exposure_remove_not_found": {
|
||||
"message": "Could not find exposure for `{group_address}` to remove."
|
||||
},
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.2.4"]
|
||||
"requirements": ["pylamarzocco==2.2.5"]
|
||||
}
|
||||
|
||||
@@ -131,7 +131,9 @@ async def async_setup_entry(
|
||||
|
||||
entities.extend(
|
||||
LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry)
|
||||
for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules
|
||||
for wake_up_sleep_entry in (
|
||||
coordinator.device.schedule.smart_wake_up_sleep.schedules
|
||||
)
|
||||
)
|
||||
|
||||
entities.append(
|
||||
|
||||
@@ -181,14 +181,9 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
|
||||
if percentage == 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
try:
|
||||
value = percentage_to_ordered_list_item(
|
||||
self._ordered_named_fan_speeds, percentage
|
||||
)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except ValueError:
|
||||
_LOGGER.exception("Failed to async_set_percentage")
|
||||
return
|
||||
value = percentage_to_ordered_list_item(
|
||||
self._ordered_named_fan_speeds, percentage
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_percentage. percentage=%s, value=%s",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Device tracker platform for LoJack integration."""
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType, TrackerEntity
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -47,11 +47,6 @@ class LoJackDeviceTracker(CoordinatorEntity[LoJackCoordinator], TrackerEntity):
|
||||
serial_number=self.coordinator.vehicle.vin,
|
||||
)
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return the latitude of the device."""
|
||||
|
||||
@@ -127,10 +127,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Reload yaml resources."""
|
||||
try:
|
||||
conf = await async_hass_config_yaml(hass)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error(err)
|
||||
return
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_reload",
|
||||
) from err
|
||||
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"failed_to_reload": {
|
||||
"message": "Failed to reload dashboard resources. Please check your configuration and try again."
|
||||
},
|
||||
"url_already_exists": {
|
||||
"message": "The URL \"{url}\" is already in use. Please choose a different one."
|
||||
}
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiolyric"],
|
||||
"requirements": ["aiolyric==2.0.2"]
|
||||
"requirements": ["aiolyric==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Button platform for Marantz IR integration.
|
||||
|
||||
Only commands that aren't already exposed by the media player live here:
|
||||
speaker A/B, source-direct toggle, and loudness toggle.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from infrared_protocols.codes.marantz.audio import MarantzAudioCode
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import MarantzIrConfigEntry
|
||||
from .const import CONF_INFRARED_EMITTER_ENTITY_ID, CONF_MODEL, MODELS
|
||||
from .entity import MarantzIrEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MarantzIrButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Marantz IR button entity."""
|
||||
|
||||
command_code: MarantzAudioCode
|
||||
|
||||
|
||||
BUTTON_DESCRIPTIONS: tuple[MarantzIrButtonEntityDescription, ...] = (
|
||||
MarantzIrButtonEntityDescription(
|
||||
key="speaker_ab",
|
||||
translation_key="speaker_ab",
|
||||
command_code=MarantzAudioCode.SPEAKER_AB,
|
||||
),
|
||||
MarantzIrButtonEntityDescription(
|
||||
key="source_direct",
|
||||
translation_key="source_direct",
|
||||
command_code=MarantzAudioCode.SOURCE_DIRECT,
|
||||
),
|
||||
MarantzIrButtonEntityDescription(
|
||||
key="loudness",
|
||||
translation_key="loudness",
|
||||
command_code=MarantzAudioCode.LOUDNESS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MarantzIrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Marantz IR buttons from config entry."""
|
||||
infrared_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID]
|
||||
model_codes = MODELS[entry.data[CONF_MODEL]].codes
|
||||
async_add_entities(
|
||||
MarantzIrButton(entry, infrared_entity_id, description)
|
||||
for description in BUTTON_DESCRIPTIONS
|
||||
if description.command_code in model_codes
|
||||
)
|
||||
|
||||
|
||||
class MarantzIrButton(MarantzIrEntity, ButtonEntity):
|
||||
"""Marantz IR button entity."""
|
||||
|
||||
entity_description: MarantzIrButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: MarantzIrConfigEntry,
|
||||
infrared_entity_id: str,
|
||||
description: MarantzIrButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Marantz IR button."""
|
||||
super().__init__(entry, infrared_entity_id, unique_id_suffix=description.key)
|
||||
self.entity_description = description
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
await self._send_marantz_command(self.entity_description.command_code)
|
||||
@@ -20,6 +20,17 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"loudness": {
|
||||
"name": "Loudness"
|
||||
},
|
||||
"source_direct": {
|
||||
"name": "Source direct"
|
||||
},
|
||||
"speaker_ab": {
|
||||
"name": "Speaker A/B"
|
||||
}
|
||||
},
|
||||
"media_player": {
|
||||
"receiver": {
|
||||
"state_attributes": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user