mirror of
https://github.com/home-assistant/core.git
synced 2026-05-19 23:35:20 +02:00
Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38e739216e | |||
| 04ad0f028e | |||
| f823ef639a | |||
| da4263b95c | |||
| 29e2184163 | |||
| 816c3ff939 | |||
| 2348ccc76e | |||
| 4202686a0d | |||
| dd1437f5f2 | |||
| 1a1c9d935c | |||
| 4c0e7eb92d | |||
| d288645f0e | |||
| 66aad8d3c5 | |||
| 89e15b9eae | |||
| 489b831a4b | |||
| f1854e1816 | |||
| 8931ce561c | |||
| 4d19cec214 | |||
| e111678c40 | |||
| 69de70407b | |||
| 64d17521a4 | |||
| b52476a37e | |||
| 58c906a2d1 | |||
| 3b2fa3f5b7 | |||
| 0dae4689cf | |||
| cd7fe836b0 | |||
| e3bae0dbda | |||
| 7cf3cba27b | |||
| de70d9ed82 | |||
| eb0c1700b7 | |||
| 6fa5fc77aa | |||
| c705e8ff56 | |||
| ee248b536e | |||
| 7bfd11cf2e | |||
| 2dae262135 | |||
| 76a463dd50 | |||
| 0d83b1cbe8 | |||
| ae622a7cd4 | |||
| 3f0af1e5b7 | |||
| 742e63d02c | |||
| 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 | |||
| 0eecb03b84 | |||
| 0c22c13b1f | |||
| bf56fad3f9 | |||
| 078d40ac54 | |||
| 1b7bda06d3 | |||
| 828dde26e5 | |||
| e8d21e57b3 | |||
| 481965eb0d | |||
| 2fcfa8320f | |||
| 95c68da115 | |||
| d547076033 | |||
| db0006c100 | |||
| f8d4826bf3 | |||
| 88f6b7159a | |||
| f7faed7330 | |||
| 302148b078 | |||
| 5b2816e56c | |||
| f7cf279648 | |||
| ee83a14391 | |||
| 833ff982d0 | |||
| d8cb3ab4b8 | |||
| 23b0f550b1 | |||
| c66eeed8f8 | |||
| bdc9d881ea | |||
| 95e2f5e219 | |||
| 68fc5c0e87 | |||
| 67c1930c6f | |||
| c90017d207 | |||
| 9dce6943de | |||
| 6a5faf2ec7 | |||
| d0711624c0 | |||
| 03ea95dfd4 | |||
| 721c736c03 | |||
| 1c105a5766 | |||
| ad0324631b | |||
| 83fbea2158 | |||
| 74c918b6b6 | |||
| ff7964bcfc | |||
| 9a1fd913bf | |||
| f0396aca8a | |||
| 018e3a4765 | |||
| 2af7f43ed7 | |||
| 95878222fd | |||
| 95f3bd7c09 | |||
| c366beab2e | |||
| 88277d5920 | |||
| 5e0aefd539 | |||
| ff313f1e7f | |||
| 70f9395d02 | |||
| b96f904d15 | |||
| 0d16fa1e65 | |||
| 27816fcb0c | |||
| 4f0faf43c6 |
+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/**"
|
||||
|
||||
+28
-1
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -128,7 +141,8 @@
|
||||
"home-assistant-bluetooth",
|
||||
"home-assistant-frontend",
|
||||
"home-assistant-intents",
|
||||
"infrared-protocols"
|
||||
"infrared-protocols",
|
||||
"rf-protocols"
|
||||
],
|
||||
"enabled": true,
|
||||
"minimumReleaseAge": null,
|
||||
@@ -183,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"],
|
||||
@@ -212,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Check requirements (changes detection)
|
||||
|
||||
# Stage 1 of the agentic Check requirements workflow.
|
||||
# Just kicks off Stage 2 (`check-requirements-dispatcher.yml`) which starts the agentic workflow
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "requirements*.txt"
|
||||
- "homeassistant/package_constraints.txt"
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Requirements files changed
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
steps:
|
||||
- name: Record PR number
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |-
|
||||
echo "Requirements files changed in PR #${PR_NUMBER}"
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Check requirements (dispatcher)
|
||||
|
||||
# Stage 2 of the agentic Check requirements workflow. Runs on completion of
|
||||
# stage 1 (`check-requirements-changes.yml`) and dispatches stage 3
|
||||
# (`check-requirements.lock.yml`)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on: # zizmor: ignore[dangerous-triggers]
|
||||
# workflow_run is safe here: this workflow does not check out PR code or run
|
||||
# any code from the triggering PR. It only resolves the PR number from the
|
||||
# head SHA and dispatches `check-requirements.lock.yml` with that number as
|
||||
# a sanitized string input. The PR code is analysed downstream in the
|
||||
# agentic workflow (`check-requirements.lock.yml`)
|
||||
workflow_run:
|
||||
workflows: ["Check requirements (changes detection)"]
|
||||
types: [completed]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: Dispatch agentic requirements check
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
&& github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write # For triggering the downstream workflow
|
||||
pull-requests: read # For querying PRs by commit SHA
|
||||
steps:
|
||||
- name: Resolve PR number from head SHA and trigger agentic requirements check
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const headBranch = context.payload.workflow_run.head_branch;
|
||||
const headRepository = context.payload.workflow_run.head_repository;
|
||||
const headRepo = headRepository.full_name;
|
||||
// Query the head repository (which may be a fork). When the PR comes
|
||||
// from a fork, the upstream's listPullRequestsAssociatedWithCommit
|
||||
// returns no results for the fork's commit SHA.
|
||||
const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: headRepository.owner.login,
|
||||
repo: headRepository.name,
|
||||
commit_sha: headSha,
|
||||
});
|
||||
const matches = pulls.filter(p =>
|
||||
p.state === 'open'
|
||||
&& p.head.ref === headBranch
|
||||
&& p.head.repo?.full_name === headRepo
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
core.info(`No open PR found for head SHA ${headSha} on ${headRepo}:${headBranch}; nothing to dispatch.`);
|
||||
return;
|
||||
}
|
||||
const defaultBranch = context.payload.workflow_run.repository.default_branch;
|
||||
for (const pr of matches) {
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'check-requirements.lock.yml',
|
||||
ref: defaultBranch,
|
||||
inputs: {
|
||||
pull_request_number: String(pr.number),
|
||||
},
|
||||
});
|
||||
core.info(`Dispatched check-requirements.lock.yml for PR #${pr.number}.`);
|
||||
}
|
||||
+1365
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
---
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pull_request_number:
|
||||
description: "Pull request number to (re-)check"
|
||||
required: true
|
||||
type: number
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
network:
|
||||
allowed:
|
||||
- python
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [default]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
max: 1
|
||||
target: ${{ inputs.pull_request_number }}
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.pull_request_number }}
|
||||
cancel-in-progress: true
|
||||
post-steps:
|
||||
- name: Verify agent produced an add_comment safe-output
|
||||
if: always()
|
||||
run: |
|
||||
OUTPUT=/tmp/gh-aw/agent_output.json
|
||||
if [ ! -f "${OUTPUT}" ]; then
|
||||
echo "::error::Agent output file ${OUTPUT} is missing; the agent did not run to completion."
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '"add_comment"' "${OUTPUT}"; then
|
||||
echo "::error::Agent did not emit an add_comment safe-output; no review comment was posted to the PR."
|
||||
echo "Agent output:"
|
||||
cat "${OUTPUT}"
|
||||
exit 1
|
||||
fi
|
||||
description: >
|
||||
Checks changed Python package requirements on PRs targeting the core repo
|
||||
(including PRs opened from forks) and verifies licenses match PyPI metadata, source
|
||||
repositories are publicly accessible, PyPI releases were uploaded via
|
||||
automated CI (Trusted Publisher attestation), the package's release pipeline
|
||||
uses OIDC or equivalent automated credentials (not static tokens), and the PR
|
||||
description contains the required links.
|
||||
---
|
||||
|
||||
# Check requirements
|
||||
|
||||
You are a code review assistant for the Home Assistant project. Your job is to
|
||||
review changes to Python package requirements and verify they meet the project's
|
||||
standards.
|
||||
|
||||
## Context
|
||||
|
||||
- Home Assistant uses `requirements_all.txt` (all integration packages),
|
||||
`requirements.txt` (core packages), `requirements_test.txt` (test
|
||||
dependencies), and `requirements_test_all.txt` (all test dependencies) to
|
||||
declare Python dependencies.
|
||||
- Each integration lists its packages in `homeassistant/components/<name>/manifest.json`
|
||||
under the `requirements` field.
|
||||
- Allowed licenses are maintained in `script/licenses.py` under
|
||||
`OSI_APPROVED_LICENSES_SPDX` (SPDX identifiers) and `OSI_APPROVED_LICENSES`
|
||||
(classifier strings).
|
||||
|
||||
## Step 1 — Identify Changed Packages
|
||||
|
||||
This workflow is triggered via `workflow_dispatch`. The PR number to check is
|
||||
**#${{ inputs.pull_request_number }}**. Use that PR number for **every** GitHub
|
||||
API call in the steps below (fetching the diff, the PR body, etc.). Do **not**
|
||||
rely on `github.event.pull_request` — it is not populated for
|
||||
`workflow_dispatch` runs.
|
||||
|
||||
Use the GitHub tool to fetch the PR diff for that PR number. Look for
|
||||
lines that were added (`+`) or removed (`-`) in **any** of these files:
|
||||
- `requirements.txt`
|
||||
- `requirements_all.txt`
|
||||
- `requirements_test.txt`
|
||||
- `requirements_test_all.txt`
|
||||
- `homeassistant/package_constraints.txt`
|
||||
- `pyproject.toml`
|
||||
|
||||
For each changed line that contains a package pin (e.g. `SomePackage==1.2.3`),
|
||||
classify it as:
|
||||
- **New package**: the package name appears only in `+` lines, with no
|
||||
corresponding `-` line for the same package name.
|
||||
- **Version bump**: the same package name appears in both `+` lines (new
|
||||
version) and `-` lines (old version), with different version numbers.
|
||||
|
||||
Record the **old version** and **new version** for every version bump — you
|
||||
will need these values in Step 4.
|
||||
|
||||
|
||||
## Step 2 — Check License via PyPI
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. Fetch `https://pypi.org/pypi/{package_name}/json` (use the exact
|
||||
package name as it appears on the requirements file).
|
||||
2. From the JSON response, extract:
|
||||
- `info.license` — free-text license field
|
||||
- `info.license_expression` — SPDX expression (if present)
|
||||
- `info.classifiers` — filter for entries starting with `"License ::"`,
|
||||
then normalize each match the same way as `script/licenses.py` by
|
||||
extracting the final ` :: ` segment (for example,
|
||||
`"License :: OSI Approved :: MIT License"` → `"MIT License"`).
|
||||
3. Determine if the license is in the approved list from `script/licenses.py`:
|
||||
- SPDX identifiers: compare against `OSI_APPROVED_LICENSES_SPDX`
|
||||
- Normalized classifier strings: compare against `OSI_APPROVED_LICENSES`
|
||||
4. Flag a package as ❌ if the license is unknown, missing, or not in the
|
||||
approved list. Flag as ⚠️ if the license information is ambiguous or cannot
|
||||
be definitively determined.
|
||||
|
||||
## Step 2b — Verify PyPI Release Was Uploaded by CI
|
||||
|
||||
For each new or bumped package, verify that the release on PyPI was published
|
||||
automatically by a CI pipeline (via OIDC Trusted Publisher), not uploaded
|
||||
manually.
|
||||
|
||||
1. Fetch the PyPI JSON for the specific version being introduced or bumped:
|
||||
`https://pypi.org/pypi/{package_name}/{version}/json`
|
||||
2. Inspect the `urls` array in the response. For each distribution file (wheel
|
||||
or sdist), note the filename.
|
||||
3. For each filename, attempt to fetch the PyPI provenance attestation:
|
||||
`https://pypi.org/integrity/{package_name}/{version}/{filename}/provenance`
|
||||
- If the response is HTTP 200 and contains a valid attestation object,
|
||||
inspect `attestation_bundles[*].publisher`. A Trusted Publisher attestation
|
||||
will have a `kind` identifying the CI system (e.g. `"GitHub Actions"`,
|
||||
`"GitLab"`) and a `repository` or `project` field matching the source
|
||||
repository.
|
||||
- If at least one distribution file has a valid Trusted Publisher attestation,
|
||||
mark ✅ CI-uploaded.
|
||||
- If no attestation is found for any file (404 for all), mark ⚠️ — "Release
|
||||
has no provenance attestation; it may have been uploaded manually".
|
||||
- If an attestation exists but the `publisher` does not identify a recognized
|
||||
CI system or Trusted Publisher, mark ⚠️ — "Attestation present but
|
||||
publisher cannot be verified as automated CI".
|
||||
|
||||
Note: if PyPI returns an error fetching the per-version JSON, fall back to the
|
||||
latest JSON (`https://pypi.org/pypi/{package_name}/json`) and look up the
|
||||
specific version in the `releases` dict.
|
||||
|
||||
## Step 3 — Identify Repository URL
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. From the PyPI JSON at `info.project_urls`, find the source repository URL
|
||||
(keys such as `"Source"`, `"Homepage"`, `"Repository"`, or `"Source Code"`).
|
||||
2. Record that repository URL for later checks.
|
||||
3. If no suitable repository URL is present, mark ❌ with a note that the
|
||||
source repository URL is missing and cannot be verified.
|
||||
|
||||
## Step 4 — Check PR Description
|
||||
|
||||
Read the PR body from the GitHub API for PR
|
||||
#${{ inputs.pull_request_number }}. Extract all URLs present in the PR body.
|
||||
|
||||
### 4a — New packages: repository link required
|
||||
|
||||
For **new packages** (brand-new dependency not previously in any requirements
|
||||
file): the PR description must contain a link that points to the package's
|
||||
**source repository** as identified in Step 3 (the URL recorded from
|
||||
`info.project_urls`). A PyPI page link alone is **not** acceptable — the link
|
||||
must point directly to the source repository (e.g. a GitHub or GitLab URL).
|
||||
|
||||
- If a URL in the PR body matches (or is a sub-path of) the source repository
|
||||
URL identified via PyPI, mark ✅.
|
||||
- If the PR body contains a source repository URL that does **not** match the
|
||||
repository URL found in the package's PyPI metadata (`info.project_urls`),
|
||||
mark ❌ — "PR description links to `<pr_url>` but PyPI reports the source
|
||||
repository as `<pypi_repo_url>`; please use the correct repository URL."
|
||||
- If no source repository URL is present in the PR body at all, mark ❌ —
|
||||
"PR description must link to the source repository at `<repo_url>` (found
|
||||
via PyPI). A PyPI page link is not sufficient."
|
||||
|
||||
### 4b — Version bumps: changelog or diff link matching the bump
|
||||
|
||||
For **version bumps**: the PR description must contain a link to a changelog,
|
||||
release notes page, or a diff/comparison URL that references the **exact
|
||||
versions** being bumped (old → new) as recorded in the diff from Step 1.
|
||||
|
||||
Checks to perform for each bumped package (old version = X, new version = Y):
|
||||
1. Extract all URLs from the PR body that contain the repository's domain or
|
||||
path (as identified in Step 3).
|
||||
2. Verify that at least one such URL includes both the old version (X) and the
|
||||
new version (Y) in some form — e.g. a GitHub compare URL like
|
||||
`compare/vX...vY`, a releases URL mentioning version Y, or a
|
||||
`CHANGELOG.md` anchor referencing Y.
|
||||
3. Confirm the link's version range matches the actual bump in the diff. If
|
||||
the link references versions different from X → Y (for example, the PR
|
||||
bumps `1.2.3 → 1.3.0` but the link points to `compare/v1.2.0...v1.2.4`),
|
||||
the link does not match the bump.
|
||||
|
||||
Outcome:
|
||||
- ✅ — a URL pointing to the correct repo with version references that match
|
||||
the exact bump (X → Y).
|
||||
- ❌ — no changelog/diff link is found, or the link does not match the actual
|
||||
bump (X → Y). Explain what was found and what is expected.
|
||||
|
||||
## Step 5 — Verify Source Repository is Publicly Accessible
|
||||
|
||||
Before inspecting the release pipeline, confirm that the source repository
|
||||
identified in Step 3 is publicly reachable.
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. Use the source repository URL recorded in Step 3.
|
||||
2. If no repository URL was found in `info.project_urls`, mark ❌ — "No source
|
||||
repository URL found in PyPI metadata; a public source repository is
|
||||
required."
|
||||
3. If a repository URL was found, perform a GET request to that URL (using
|
||||
web-fetch). If the response is HTTP 200 and returns a publicly accessible
|
||||
page (not a login redirect or error page), mark ✅.
|
||||
4. If the response is non-200, the URL redirects to a login/authentication page,
|
||||
or the repository appears private or unavailable, mark ❌ — "Source
|
||||
repository at `<repo_url>` is not publicly accessible. Home Assistant
|
||||
requires all dependencies to have publicly available source code." **Do not
|
||||
proceed with the release pipeline check (Step 6) for this package.**
|
||||
|
||||
## Step 6 — Check Release Pipeline Sanity
|
||||
|
||||
For each new or bumped package, determine the source repository host from the
|
||||
URL identified in Step 3, then inspect whether the project's release/publish CI
|
||||
workflow is sane. The checks differ by hosting provider.
|
||||
|
||||
### GitHub repositories (`github.com`)
|
||||
|
||||
1. Using the GitHub API, list the workflows in the source repository:
|
||||
`GET /repos/{owner}/{repo}/actions/workflows`
|
||||
2. Identify any workflow whose name or filename suggests publishing to PyPI
|
||||
(e.g., contains "release", "publish", "pypi", or "deploy").
|
||||
3. Fetch the workflow file content and check the following:
|
||||
a. **Trigger sanity**: The publish job should be triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job — **not** solely
|
||||
by `workflow_dispatch` with no additional guards. A `workflow_dispatch`
|
||||
trigger alongside other triggers is acceptable. Mark ❌ if the only trigger
|
||||
is manual `workflow_dispatch` with no environment protection rules.
|
||||
b. **OIDC / Trusted Publisher**: The workflow should use OIDC-based publishing.
|
||||
Look for `id-token: write` permission and one of:
|
||||
- `pypa/gh-action-pypi-publish` action
|
||||
- `actions/attest-build-provenance` action
|
||||
- Any step that sets `TWINE_PASSWORD` from `secrets.PYPI_TOKEN` directly
|
||||
(treat this as a static long-lived API token rather than OIDC).
|
||||
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined.
|
||||
If a static secret token is the only credential, mark ⚠️ for version
|
||||
bumps (the package was already accepted at a previous version; suggest
|
||||
the upstream maintainer switch to OIDC / Trusted Publisher for better
|
||||
security) and ❌ for new packages.
|
||||
c. **No manual upload bypass**: Verify there is no step that calls
|
||||
`twine upload` or `pip upload` outside of a properly gated job (e.g., one
|
||||
that requires an environment approval). Flag ⚠️ if such steps exist.
|
||||
4. If no publish workflow is found in the repository, mark ⚠️ — "No publish
|
||||
workflow found; it is unclear how this package is released to PyPI."
|
||||
|
||||
### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Use the GitLab REST API to list CI/CD pipeline configuration files. First
|
||||
resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`
|
||||
and note the `id` field.
|
||||
2. Fetch the repository's `.gitlab-ci.yml` (and any included files) using
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`
|
||||
(use web-fetch for public repos).
|
||||
3. Identify any job whose name or `stage` suggests publishing to PyPI
|
||||
(e.g., "publish", "deploy", "release", "pypi").
|
||||
4. For each such job, check:
|
||||
a. **Trigger sanity**: The job should run only on tag pipelines (`only: tags`
|
||||
or `rules: - if: $CI_COMMIT_TAG`) or on protected branches — **not**
|
||||
solely on manual triggers (`when: manual`) with no additional protection.
|
||||
Mark ❌ if the only trigger is manual with no environment or protected-branch
|
||||
guard.
|
||||
b. **Automated credentials**: The job should use GitLab's OIDC ID token
|
||||
(`id_tokens:` block) and `pypa/gh-action-pypi-publish` equivalent, or
|
||||
reference `secrets.PYPI_TOKEN` / `$PYPI_TOKEN` injected from GitLab CI/CD
|
||||
protected variables. Flag ❌ if the token is hard-coded or unprotected.
|
||||
Mark ✅ if OIDC is used, ⚠️ if the method cannot be determined. If a
|
||||
protected static token is the only credential, mark ⚠️ for version bumps
|
||||
(suggest the upstream maintainer switch to OIDC / Trusted Publisher for
|
||||
better security) and ❌ for new packages.
|
||||
c. **No manual upload bypass**: Flag ⚠️ if any job calls `twine upload`
|
||||
without being behind a protected-variable or environment guard.
|
||||
5. If no publish job is found, mark ⚠️ — "No publish job found in .gitlab-ci.yml;
|
||||
it is unclear how this package is released to PyPI."
|
||||
|
||||
### Other code hosting providers
|
||||
|
||||
For repositories hosted on platforms other than GitHub or GitLab (e.g.,
|
||||
Bitbucket, Codeberg, Gitea, Sourcehut):
|
||||
1. Use web-fetch to retrieve the repository's root page and look for any
|
||||
publicly visible CI configuration files (e.g., `.circleci/config.yml`,
|
||||
`Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`,
|
||||
`.builds/*.yml` for Sourcehut).
|
||||
2. Apply the same conceptual checks as above:
|
||||
- Does publishing run on automated triggers (tags/releases), not solely
|
||||
manual ones?
|
||||
- Are credentials injected by the CI system (not hard-coded)?
|
||||
- Is there a `twine upload` or equivalent step that could be run manually?
|
||||
3. If no CI configuration can be retrieved, mark ⚠️ — "Release pipeline could
|
||||
not be inspected; hosting provider is not GitHub or GitLab."
|
||||
|
||||
## Step 7 — Post a Review Comment
|
||||
|
||||
**Always** post a review comment using `add_comment`, regardless of whether
|
||||
packages pass or fail. Use the following structure:
|
||||
|
||||
**Note on deduplication**: The workflow automatically updates any previous
|
||||
requirements-check comment on the PR in place (preserving its position in the
|
||||
thread). If no previous comment exists, the newly created comment is kept as-is.
|
||||
You do not need to search for or update previous comments yourself.
|
||||
|
||||
### Comment structure
|
||||
|
||||
Begin every comment with the HTML marker `<!-- requirements-check -->` on its
|
||||
own line (this is used by the workflow to find the previous comment and update
|
||||
it on the next run).
|
||||
|
||||
### 7a — Overall summary line
|
||||
|
||||
Begin the comment with a single summary line, before anything else:
|
||||
|
||||
- If everything passed: `All requirements checks passed. ✅`
|
||||
- If there are failures or warnings: `⚠️ Some checks require attention — see the details below.`
|
||||
|
||||
### 7b — Summary table
|
||||
|
||||
Render a compact table where every check column contains **only the status
|
||||
icon** (✅, ⚠️, or ❌). No explanatory text belongs inside the table cells —
|
||||
all detail goes in the per-package sections below.
|
||||
|
||||
Use `—` (em dash) when a check was skipped (e.g. Release Pipeline is skipped
|
||||
when the repository is not publicly accessible).
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
| Package | Type | Old→New | License | Repo Public | CI Upload | Release Pipeline | PR Link |
|
||||
|---------|------|---------|---------|-------------|-----------|------------------|---------|
|
||||
| PackageA | bump | 1.2.3→1.3.0 | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| PackageB | new | —→4.5.6 | ❌ | ✅ | ⚠️ | ⚠️ | ❌ |
|
||||
| PackageC | bump | 2.0.0→2.1.0 | ✅ | ❌ | — | — | ❌ |
|
||||
```
|
||||
|
||||
### 7c — Per-package detail sections
|
||||
|
||||
After the table, add one collapsible `<details>` block per package.
|
||||
|
||||
- If **all checks passed** for that package, render the block **collapsed**
|
||||
(no `open` attribute) so the comment stays concise.
|
||||
- If **any check failed or produced a warning**, render the block **open**
|
||||
(`<details open>`) so the contributor sees the issues immediately.
|
||||
|
||||
Each block must include the full detail for every check: the license found, the
|
||||
repository URL, whether a provenance attestation was found, the release
|
||||
pipeline findings, and the PR link found (or missing, or mismatched with the
|
||||
actual bump). For failed or warned checks, explain exactly what the contributor
|
||||
must fix, including the expected source repository URL, expected version range,
|
||||
etc.
|
||||
|
||||
Template (repeat for each package):
|
||||
|
||||
```
|
||||
<details open>
|
||||
<summary><strong>PackageB 📦 new —→4.5.6</strong></summary>
|
||||
|
||||
- **License**: ❌ License is `UNKNOWN` — not in the approved list. Check PyPI metadata and `script/licenses.py`.
|
||||
- **Repository Public**: ✅ https://github.com/example/packageb is publicly accessible.
|
||||
- **CI Upload**: ⚠️ No provenance attestation found for any distribution file. The release may have been uploaded manually.
|
||||
- **Release Pipeline**: ⚠️ No publish workflow found in the repository; it is unclear how this package is released to PyPI.
|
||||
- **PR Link**: ❌ PR description must link to the source repository at https://github.com/example/packageb (a PyPI page link is not sufficient).
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
Collapsed example (all checks passed):
|
||||
|
||||
```
|
||||
<details>
|
||||
<summary><strong>PackageA 📦 bump 1.2.3→1.3.0</strong></summary>
|
||||
|
||||
- **License**: ✅ MIT
|
||||
- **Repository Public**: ✅ https://github.com/example/packagea
|
||||
- **CI Upload**: ✅ Trusted Publisher attestation found (GitHub Actions).
|
||||
- **Release Pipeline**: ✅ OIDC via `pypa/gh-action-pypi-publish`; triggered on `release: published`; `environment: release` gate.
|
||||
- **PR Link**: ✅ https://github.com/example/packagea/compare/v1.2.3...v1.3.0
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive and helpful. Provide direct links where possible so the
|
||||
contributor can quickly fix the issue.
|
||||
- If PyPI returns an error for a package, mention that it could not be found and
|
||||
suggest the contributor verify the package name.
|
||||
- For packages that only appear in `homeassistant/package_constraints.txt` or
|
||||
`pyproject.toml` without being tied to a specific integration, the PR
|
||||
description link requirement still applies.
|
||||
- When checking test-only packages (from `requirements_test.txt` or
|
||||
`requirements_test_all.txt`), apply the same license, repository, and PR
|
||||
description checks as for production dependencies.
|
||||
- A package that appears in both a production file and a test file should only
|
||||
be reported once; use the production file entry as the canonical one.
|
||||
- This workflow is invoked exclusively via `workflow_dispatch`. The stage-1
|
||||
workflow `Check requirements (changes detection)` runs on `pull_request` with
|
||||
a paths filter on the tracked requirements files, and its completion triggers
|
||||
the dispatcher (`Check requirements (dispatcher)`) which calls this workflow
|
||||
with the PR number. Members can also dispatch this workflow manually with the
|
||||
PR number to re-run the check after updating the PR description or fixing
|
||||
issues without changing any requirements files. On a retrigger the existing
|
||||
comment is updated in place so there is always exactly one requirements-check
|
||||
comment in the PR.
|
||||
@@ -1088,6 +1088,7 @@ jobs:
|
||||
options: >-
|
||||
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
|
||||
--health-interval=5s --health-timeout=2s --health-retries=3
|
||||
--tmpfs /var/lib/mysql:size=2g,mode=0750
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
@@ -1245,7 +1246,10 @@ jobs:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_PASSWORD: password
|
||||
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
options: >-
|
||||
--health-cmd="pg_isready -hlocalhost -Upostgres"
|
||||
--health-interval=5s --health-timeout=2s --health-retries=3
|
||||
--tmpfs /var/lib/postgresql/data:size=2g,mode=0700
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
|
||||
@@ -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
+2
@@ -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
|
||||
|
||||
+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/
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.7.3"]
|
||||
"requirements": ["serialx==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ from .hub import AdsHub
|
||||
|
||||
DEFAULT_NAME = "ADS select"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.6.0"]
|
||||
"requirements": ["aioamazondevices==13.7.0"]
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
DOMAIN = "altruist"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_HOST = "host"
|
||||
|
||||
@@ -24,10 +24,10 @@ from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LLM_HASS_API,
|
||||
CONF_NAME,
|
||||
CONF_PROMPT,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import 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,
|
||||
@@ -44,12 +44,13 @@ from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_CODE_EXECUTION,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_PROMPT_CACHING,
|
||||
CONF_RECOMMENDED,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_TOOL_SEARCH,
|
||||
CONF_WEB_FETCH,
|
||||
CONF_WEB_FETCH_MAX_USES,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
||||
DEFAULT_AI_TASK_NAME = "Claude AI Task"
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
CONF_CODE_EXECUTION = "code_execution"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
@@ -18,6 +17,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 +46,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,
|
||||
|
||||
@@ -4,12 +4,12 @@ from typing import Literal
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .const import DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@ from typing import TYPE_CHECKING, Any
|
||||
from anthropic import __title__, __version__
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PROMPT
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
|
||||
@@ -17,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:
|
||||
|
||||
@@ -40,9 +40,11 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._current_subentry_id = None
|
||||
self._model_list_cache = None
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the steps of a fix flow."""
|
||||
if user_input.get(CONF_CHAT_MODEL):
|
||||
if user_input and user_input.get(CONF_CHAT_MODEL):
|
||||
self._async_update_current_subentry(user_input)
|
||||
|
||||
target = await self._async_next_target()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -16,6 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
# Read-only, coordinator-driven entities; no per-entity I/O to bound.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
@@ -53,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,15 +16,19 @@ 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__)
|
||||
|
||||
# arcam-fmj serializes commands on a single TCP writer at the library
|
||||
# layer; serialize at HA's layer to match the device's contract.
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -41,23 +43,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 +64,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 +170,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",
|
||||
|
||||
@@ -22,6 +22,9 @@ from .entity import ArcamFmjEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Read-only, coordinator-driven entities; no per-entity I/O to bound.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
def _enum_options(value: type[IntOrTypeEnum]) -> list[str]:
|
||||
return [
|
||||
|
||||
@@ -19,6 +19,8 @@ DEVICES = "devices"
|
||||
MANUFACTURER = "ABB"
|
||||
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL = "model"
|
||||
ATTR_FIRMWARE = "firmware"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,6 +11,7 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
AWS_DOMAIN = "amazonaws.com"
|
||||
|
||||
@@ -20,12 +20,12 @@ from homeassistant.components.backup import (
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util.async_iterator import AsyncIteratorReader
|
||||
|
||||
from . import BackblazeConfigEntry
|
||||
from .const import (
|
||||
CONF_PREFIX,
|
||||
DATA_BACKUP_AGENT_LISTENERS,
|
||||
DOMAIN,
|
||||
METADATA_FILE_SUFFIX,
|
||||
@@ -175,12 +175,13 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
"Attempting to delete partially uploaded backup file %s",
|
||||
filename,
|
||||
)
|
||||
|
||||
def _delete_uploaded_file() -> None:
|
||||
"""Look up and delete the partially uploaded backup file."""
|
||||
self._bucket.get_file_info_by_name(filename).delete()
|
||||
|
||||
try:
|
||||
uploaded_main_file_info = await self._hass.async_add_executor_job(
|
||||
self._bucket.get_file_info_by_name, filename
|
||||
)
|
||||
# pylint: disable-next=home-assistant-sequential-executor-jobs
|
||||
await self._hass.async_add_executor_job(uploaded_main_file_info.delete)
|
||||
await self._hass.async_add_executor_job(_delete_uploaded_file)
|
||||
except B2Error:
|
||||
_LOGGER.warning(
|
||||
"Failed to clean up partially uploaded backup file %s;"
|
||||
@@ -386,9 +387,12 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
metadata_file.file_name,
|
||||
)
|
||||
|
||||
await self._hass.async_add_executor_job(file.delete)
|
||||
# pylint: disable-next=home-assistant-sequential-executor-jobs
|
||||
await self._hass.async_add_executor_job(metadata_file.delete)
|
||||
def _delete_backup_files() -> None:
|
||||
"""Delete the backup file and its metadata file."""
|
||||
file.delete()
|
||||
metadata_file.delete()
|
||||
|
||||
await self._hass.async_add_executor_job(_delete_backup_files)
|
||||
|
||||
self._invalidate_caches(
|
||||
backup_id,
|
||||
|
||||
@@ -8,6 +8,7 @@ from b2sdk.v2 import exception
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
@@ -22,7 +23,6 @@ from .const import (
|
||||
CONF_APPLICATION_KEY,
|
||||
CONF_BUCKET,
|
||||
CONF_KEY_ID,
|
||||
CONF_PREFIX,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ DOMAIN: Final = "backblaze_b2"
|
||||
CONF_KEY_ID = "key_id"
|
||||
CONF_APPLICATION_KEY = "application_key"
|
||||
CONF_BUCKET = "bucket"
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -16,6 +16,7 @@ CONF_DETAILS = "details"
|
||||
CONF_PASSIVE = "passive"
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_SOURCE: Final = "source"
|
||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||
CONF_SOURCE_MODEL: Final = "source_model"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Final
|
||||
ATTR_CID: Final = "cid"
|
||||
ATTR_MAC: Final = "macAddr"
|
||||
ATTR_MANUFACTURER: Final = "Sony"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL: Final = "model"
|
||||
|
||||
CONF_NICKNAME: Final = "nickname"
|
||||
|
||||
@@ -8,6 +8,7 @@ from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
@@ -20,7 +21,6 @@ if TYPE_CHECKING:
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
ATTR_MONDAY_SLOTS = "monday_slots"
|
||||
ATTR_TUESDAY_SLOTS = "tuesday_slots"
|
||||
ATTR_WEDNESDAY_SLOTS = "wednesday_slots"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
DOMAIN = "calendar"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_EVENT = "event"
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiostreammagic==2.13.0"],
|
||||
"requirements": ["aiostreammagic==2.13.1"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,7 @@ class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]):
|
||||
"""Return additional sensor state attributes."""
|
||||
return {
|
||||
"is_valid": self.coordinator.is_cert_valid,
|
||||
"error": str(self.coordinator.cert_error),
|
||||
"error": str(self.coordinator.cert_error)
|
||||
if self.coordinator.cert_error
|
||||
else None,
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@ HA_USER_AGENT = (
|
||||
)
|
||||
|
||||
ATTR_UID = "uid"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LATITUDE = "latitude"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LONGITUDE = "longitude"
|
||||
ATTR_EMPTY_SLOTS = "empty_slots"
|
||||
ATTR_FREE_EBIKES = "free_ebikes"
|
||||
|
||||
@@ -29,6 +29,7 @@ BASE_API_URL = "https://rest.clicksend.com/v3"
|
||||
|
||||
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_LANGUAGE = "language"
|
||||
CONF_VOICE = "voice"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -11,6 +11,7 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
# R2 is S3-compatible. Endpoint should be like:
|
||||
|
||||
@@ -6,4 +6,5 @@ ATTR_URL = "color_extract_url"
|
||||
DOMAIN = "color_extractor"
|
||||
DEFAULT_NAME = "Color extractor"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_TURN_ON = "turn_on"
|
||||
|
||||
@@ -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,10 +10,11 @@ from homeassistant.components.notify import (
|
||||
)
|
||||
from homeassistant.const import CONF_COMMAND
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util.process import kill_subprocess
|
||||
|
||||
from .const import CONF_COMMAND_TIMEOUT, LOGGER
|
||||
from .const import CONF_COMMAND_TIMEOUT, DOMAIN, LOGGER
|
||||
from .utils import create_platform_yaml_not_supported_issue, render_template_args
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -66,9 +67,18 @@ class CommandLineNotificationService(BaseNotificationService):
|
||||
proc.returncode,
|
||||
command,
|
||||
)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except subprocess.TimeoutExpired:
|
||||
_LOGGER.error("Timeout for command: %s", command)
|
||||
except subprocess.TimeoutExpired as err:
|
||||
_LOGGER.debug("Timeout for command: %s", command)
|
||||
kill_subprocess(proc)
|
||||
except subprocess.SubprocessError:
|
||||
_LOGGER.error("Error trying to exec command: %s", command)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_error",
|
||||
translation_placeholders={"command": command},
|
||||
) from err
|
||||
except subprocess.SubprocessError as err:
|
||||
_LOGGER.debug("Error trying to exec command: %s", command)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error",
|
||||
translation_placeholders={"command": command, "error": str(err)},
|
||||
) from err
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"command_error": {
|
||||
"message": "Error trying to execute command: {command}. Error: {error}"
|
||||
},
|
||||
"timeout_error": {
|
||||
"message": "Timeout trying to execute command: {command}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"platform_yaml_not_supported": {
|
||||
"description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.",
|
||||
|
||||
@@ -16,6 +16,7 @@ PLATFORMS = [
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
|
||||
|
||||
@@ -279,6 +279,20 @@
|
||||
"no_alarm": "mdi:check-circle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"device_on_off": {
|
||||
"default": "mdi:power",
|
||||
"state": {
|
||||
"off": "mdi:power-off"
|
||||
}
|
||||
},
|
||||
"force_dhw": {
|
||||
"default": "mdi:water-boiler",
|
||||
"state": {
|
||||
"off": "mdi:water-boiler-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +421,14 @@
|
||||
"weather_curve": {
|
||||
"name": "Weather curve"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"device_on_off": {
|
||||
"name": "Device on/off"
|
||||
},
|
||||
"force_dhw": {
|
||||
"name": "Force domestic hot water"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Switch platform for Compit integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api.consts import CompitParameter
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER_NAME
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CompitDeviceDescription:
|
||||
"""Class to describe a Compit device."""
|
||||
|
||||
name: str
|
||||
"""Name of the device."""
|
||||
|
||||
parameters: list[SwitchEntityDescription]
|
||||
"""Parameters of the device."""
|
||||
|
||||
|
||||
DESCRIPTIONS: dict[CompitParameter, SwitchEntityDescription] = {
|
||||
CompitParameter.DEVICE_ON_OFF: SwitchEntityDescription(
|
||||
key=CompitParameter.DEVICE_ON_OFF.value,
|
||||
translation_key="device_on_off",
|
||||
),
|
||||
CompitParameter.FORCE_DHW: SwitchEntityDescription(
|
||||
key=CompitParameter.FORCE_DHW.value,
|
||||
translation_key="force_dhw",
|
||||
),
|
||||
}
|
||||
|
||||
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
|
||||
210: CompitDeviceDescription(
|
||||
name="EL750",
|
||||
parameters=[DESCRIPTIONS[CompitParameter.DEVICE_ON_OFF]],
|
||||
),
|
||||
224: CompitDeviceDescription(
|
||||
name="R 900",
|
||||
parameters=[
|
||||
DESCRIPTIONS[CompitParameter.FORCE_DHW],
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CompitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Compit switch entities from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
CompitSwitch(
|
||||
coordinator,
|
||||
device_id,
|
||||
device_definition.name,
|
||||
entity_description,
|
||||
)
|
||||
for device_id, device in coordinator.connector.all_devices.items()
|
||||
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
|
||||
for entity_description in device_definition.parameters
|
||||
)
|
||||
|
||||
|
||||
class CompitSwitch(CoordinatorEntity[CompitDataUpdateCoordinator], SwitchEntity):
|
||||
"""Representation of a Compit switch entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CompitDataUpdateCoordinator,
|
||||
device_id: int,
|
||||
device_name: str,
|
||||
entity_description: SwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch entity."""
|
||||
super().__init__(coordinator)
|
||||
self.device_id = device_id
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{device_id}_{entity_description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(device_id))},
|
||||
name=device_name,
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
model=device_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.connector.get_device(self.device_id) is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the switch."""
|
||||
value = self.coordinator.connector.get_current_option(
|
||||
self.device_id, CompitParameter(self.entity_description.key)
|
||||
)
|
||||
|
||||
return True if value == STATE_ON else False if value == STATE_OFF else None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.coordinator.connector.select_device_option(
|
||||
self.device_id, CompitParameter(self.entity_description.key), STATE_ON
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.coordinator.connector.select_device_option(
|
||||
self.device_id, CompitParameter(self.entity_description.key), STATE_OFF
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
@@ -272,7 +272,10 @@ class Control4Climate(Control4Entity, ClimateEntity):
|
||||
if data is None:
|
||||
return None
|
||||
humidity = data.get(CONTROL4_HUMIDITY)
|
||||
return int(humidity) if humidity is not None else None
|
||||
try:
|
||||
return int(humidity) if humidity is not None else None
|
||||
except ValueError, TypeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
|
||||
@@ -19,6 +19,7 @@ ATTR_AGENT_ID = "agent_id"
|
||||
ATTR_CONVERSATION_ID = "conversation_id"
|
||||
|
||||
SERVICE_PROCESS = "process"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_RELOAD = "reload"
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
|
||||
|
||||
@@ -10,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()
|
||||
|
||||
@@ -4,10 +4,11 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pydaikin.daikin_base import Appliance
|
||||
from pydaikin.exceptions import DaikinException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, TIMEOUT_SEC
|
||||
|
||||
@@ -33,4 +34,11 @@ class DaikinCoordinator(DataUpdateCoordinator[None]):
|
||||
self.device = device
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
await self.device.update_status()
|
||||
try:
|
||||
await self.device.update_status()
|
||||
except DaikinException as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_communicating",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"error_communicating": {
|
||||
"message": "Error communicating with Daikin device: {error}"
|
||||
},
|
||||
"zone_hvac_mode_unsupported": {
|
||||
"message": "Zone temperature can only be changed when the main climate mode is heat or cool."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
ATTR_DARK = "dark"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCKED = "locked"
|
||||
ATTR_OFFSET = "offset"
|
||||
ATTR_ON = "on"
|
||||
|
||||
@@ -12,9 +12,9 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_BATTERY_LEVEL,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
@@ -37,49 +37,71 @@ async def async_setup_entry(
|
||||
async_add_entities(
|
||||
[
|
||||
DemoSensor(
|
||||
"sensor_1",
|
||||
"sensor_1",
|
||||
"Outside Temperature",
|
||||
15.6,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
UnitOfTemperature.CELSIUS,
|
||||
12,
|
||||
),
|
||||
DemoSensor(
|
||||
"battery_1",
|
||||
"sensor_1",
|
||||
"Outside Temperature",
|
||||
12,
|
||||
SensorDeviceClass.BATTERY,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_name="Battery",
|
||||
),
|
||||
DemoSensor(
|
||||
"sensor_2",
|
||||
"sensor_2",
|
||||
"Outside Humidity",
|
||||
54,
|
||||
SensorDeviceClass.HUMIDITY,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
PERCENTAGE,
|
||||
None,
|
||||
),
|
||||
DemoSensor(
|
||||
"sensor_3",
|
||||
"sensor_3",
|
||||
"Carbon monoxide",
|
||||
54,
|
||||
SensorDeviceClass.CO,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
None,
|
||||
),
|
||||
DemoSensor(
|
||||
"sensor_4",
|
||||
"sensor_4",
|
||||
"Carbon dioxide",
|
||||
54,
|
||||
SensorDeviceClass.CO2,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
14,
|
||||
),
|
||||
DemoSensor(
|
||||
"battery_4",
|
||||
"sensor_4",
|
||||
"Carbon dioxide",
|
||||
99,
|
||||
SensorDeviceClass.BATTERY,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_name="Battery",
|
||||
),
|
||||
DemoSensor(
|
||||
"sensor_5",
|
||||
"sensor_5",
|
||||
"Power consumption",
|
||||
100,
|
||||
SensorDeviceClass.POWER,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
UnitOfPower.WATT,
|
||||
None,
|
||||
),
|
||||
DemoSumSensor(
|
||||
"sensor_6",
|
||||
@@ -88,7 +110,6 @@ async def async_setup_entry(
|
||||
SensorDeviceClass.ENERGY,
|
||||
SensorStateClass.TOTAL,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
None,
|
||||
"total_energy_kwh",
|
||||
),
|
||||
DemoSumSensor(
|
||||
@@ -98,7 +119,6 @@ async def async_setup_entry(
|
||||
SensorDeviceClass.ENERGY,
|
||||
SensorStateClass.TOTAL,
|
||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
None,
|
||||
"total_energy_mwh",
|
||||
),
|
||||
DemoSumSensor(
|
||||
@@ -108,7 +128,6 @@ async def async_setup_entry(
|
||||
SensorDeviceClass.GAS,
|
||||
SensorStateClass.TOTAL,
|
||||
UnitOfVolume.CUBIC_METERS,
|
||||
None,
|
||||
"total_gas_m3",
|
||||
),
|
||||
DemoSumSensor(
|
||||
@@ -118,17 +137,16 @@ async def async_setup_entry(
|
||||
SensorDeviceClass.GAS,
|
||||
SensorStateClass.TOTAL,
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
None,
|
||||
"total_gas_ft3",
|
||||
),
|
||||
DemoSensor(
|
||||
unique_id="sensor_10",
|
||||
device_id="sensor_10",
|
||||
device_name="Thermostat",
|
||||
state="eco",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
state_class=None,
|
||||
unit_of_measurement=None,
|
||||
battery=None,
|
||||
options=["away", "comfort", "eco", "sleep"],
|
||||
translation_key="thermostat_mode",
|
||||
),
|
||||
@@ -140,20 +158,21 @@ class DemoSensor(SensorEntity):
|
||||
"""Representation of a Demo sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
device_id: str,
|
||||
device_name: str | None,
|
||||
state: float | str | None,
|
||||
device_class: SensorDeviceClass,
|
||||
state_class: SensorStateClass | None,
|
||||
unit_of_measurement: str | None,
|
||||
battery: int | None,
|
||||
options: list[str] | None = None,
|
||||
translation_key: str | None = None,
|
||||
entity_category: EntityCategory | None = None,
|
||||
entity_name: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._attr_device_class = device_class
|
||||
@@ -163,15 +182,14 @@ class DemoSensor(SensorEntity):
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_options = options
|
||||
self._attr_translation_key = translation_key
|
||||
self._attr_entity_category = entity_category
|
||||
self._attr_name = entity_name
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=device_name,
|
||||
)
|
||||
|
||||
if battery:
|
||||
self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery}
|
||||
|
||||
|
||||
class DemoSumSensor(RestoreSensor):
|
||||
"""Representation of a Demo sensor."""
|
||||
@@ -187,7 +205,6 @@ class DemoSumSensor(RestoreSensor):
|
||||
device_class: SensorDeviceClass,
|
||||
state_class: SensorStateClass | None,
|
||||
unit_of_measurement: str | None,
|
||||
battery: int | None,
|
||||
suggested_entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
@@ -204,9 +221,6 @@ class DemoSumSensor(RestoreSensor):
|
||||
name=device_name,
|
||||
)
|
||||
|
||||
if battery:
|
||||
self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery}
|
||||
|
||||
@callback
|
||||
def _async_bump_sum(self, now: datetime) -> None:
|
||||
"""Bump the sum."""
|
||||
|
||||
@@ -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}"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
CONF_COMMAND_TOPIC = "drop_command_topic"
|
||||
CONF_DATA_TOPIC = "drop_data_topic"
|
||||
CONF_DEVICE_DESC = "device_desc"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE_ID = "device_id"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
CONF_HUB_ID = "drop_hub_id"
|
||||
|
||||
@@ -49,6 +49,7 @@ CURRENT_SYSTEM_PRESSURE = "current_system_pressure"
|
||||
HIGH_SYSTEM_PRESSURE = "high_system_pressure"
|
||||
LOW_SYSTEM_PRESSURE = "low_system_pressure"
|
||||
BATTERY = "battery"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
TEMPERATURE = "temperature"
|
||||
INLET_TDS = "inlet_tds"
|
||||
OUTLET_TDS = "outlet_tds"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco_connectivity"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-connectivity==0.4.0"],
|
||||
"requirements": ["python-duco-connectivity==0.5.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Constants for the ElevenLabs text-to-speech integration."""
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL = "model"
|
||||
|
||||
CONF_VOICE = "voice"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_MODEL = "model"
|
||||
CONF_CONFIGURE_VOICE = "configure_voice"
|
||||
CONF_STABILITY = "stability"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user