Compare commits

...

90 Commits

Author SHA1 Message Date
Paul Bottein fd93c112ed Check media id format 2026-05-19 14:14:24 +02:00
Paul Bottein fcc0ab5452 Remove status calls 2026-05-19 13:53:15 +02:00
Paul Bottein 26804ab408 Bump requirements 2026-05-19 12:10:39 +02:00
Paul Bottein 177dcbc751 Clean up 2026-05-19 12:10:39 +02:00
Paul Bottein 6d64d98250 Improve test naming 2026-05-19 12:10:39 +02:00
Paul Bottein 8234c61ca8 Improve coverage 2026-05-19 12:10:39 +02:00
Paul Bottein 99d6be1097 Migrate Yoto integration to async client 2026-05-19 12:10:39 +02:00
Paul Bottein e720c1b378 Continue integration 2026-05-19 12:10:39 +02:00
Paul Bottein 4a0ba0a830 Bump lib version 2026-05-19 12:10:39 +02:00
Paul Bottein 4fb1aa6923 WIP: Add yoto integration 2026-05-19 12:10:38 +02:00
nopoz 1042ec2964 Bump pyenvisalink to 4.9 (#171125) 2026-05-19 12:03:20 +02:00
Erik Montnemery f4fdd4d58f Adjust device tracker tests (#171178) 2026-05-19 11:52:12 +02:00
iluvdata 3963555b2f Add RepairsFlowResult pylint check (#171145) 2026-05-19 11:35:25 +02:00
Erwin Douna 4f8885b40d Downloader add proper exceptions (#170771)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 11:35:01 +02:00
Robert Resch 3f49877ff1 Use renovate to update go2rtc (#169508) 2026-05-19 10:10:17 +02:00
Erik Montnemery d2bb31d115 Remove useless input validation from cast options flow (#171171) 2026-05-19 10:09:17 +02:00
Denis Shulyaka f499dbf29f Add web fetch tool support for Anthropic (#167405)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-19 09:50:50 +02:00
Keith Roehrenbeck bc0e3dc3be Fix Apple TV keyboard focus binary_sensor missing on cold start (#170360) 2026-05-19 09:38:48 +02:00
Florent Thoumie fb6e6170bf Improve iaqualink 429 handling (#170231) 2026-05-19 09:24:09 +02:00
Mick Vleeshouwer 9e22711874 Fix controls for UpDownGarageDoor4T and additional 4T covers in Overkiz (#171144) 2026-05-19 09:22:31 +02:00
puddly 1982dd9085 Fix ZHA config entries using a URI without a port (#171164) 2026-05-19 09:14:48 +02:00
Adam Katic c32098decd Add quality scale for speedtestdotnet integration (#170782) 2026-05-19 10:06:06 +03:00
Erik Montnemery 2e87750d70 Remove use of advanced mode from the cast integration (#171090) 2026-05-19 08:26:35 +02:00
karwosts 55354770a8 Make energy electric sources nameable (#170658) 2026-05-19 09:22:42 +03:00
epenet d7b63a40db Rename Tuya fixtures (#171169) 2026-05-19 08:22:09 +02:00
Erik Montnemery c80d1ba003 Correct signature of mock class in test_recovery_from_dbus_restart (#171097) 2026-05-19 07:48:12 +02:00
Paulus Schoutsen e675423c3c add /local to no auth sig required urls (#171140)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
2026-05-19 06:55:47 +02:00
Marcos A L M Macedo 11cbf91563 Add total production sensor support for Tuya SPM02 devices (#171166) 2026-05-19 06:51:46 +02:00
yemua 4d5c36a3c1 Enable current/power/voltage sensors by default for Tuya electrical categories (#171098) 2026-05-19 06:45:58 +02:00
Carlos Sánchez López cc335a3bd9 Add number support for Tuya WG2 alarm panel (Duosmart C30) (#165836)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-19 06:38:12 +02:00
Josh Gustafson f764a32564 Use device name in arcam_fmj browse media root (#171160)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:49:22 -04:00
Josh Gustafson aeb7109708 Share arcam_fmj convert_exception decorator from entity module (#171162)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:01:28 -04:00
Josh Gustafson f75c205c08 Annotate parametrized arcam_fmj media_player test signatures (#171163)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:57:24 -04:00
Joost Lekkerkerker e20f4c8f6e Use subentry helper in Satel Integra (#167060)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-19 01:08:53 +02:00
Erik Montnemery 72f6c38e7d Remove use of advanced mode from the tasmota integration (#171093) 2026-05-19 00:43:08 +02:00
Erik Montnemery 40408def0f Don't set _attr_source_type in victron_gx device tracker entity (#171077) 2026-05-19 00:40:05 +02:00
Robert Resch 282737e3c4 Bump gh aw to 0.74.4 (#171137) 2026-05-18 23:02:56 +01:00
Josh Gustafson a1cc735337 Report unknown state in arcam_fmj when power state is unreported (#171149)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:01:51 +01:00
Mick Vleeshouwer b6f4551a76 Add light entity tests to Overkiz (#171102) 2026-05-18 23:46:15 +02:00
Phil-Rad f5d2aa9c12 Use runtime_data and validate connection at setup for dnsip (#169745) 2026-05-18 23:24:57 +02:00
Onero-testdev 612dbf2d44 Add SwitchBot Permanent Outdoor Light support (#170463)
Co-authored-by: Fan Kai <fankai@onero.com>
2026-05-18 23:22:56 +02:00
Maciej Bieniek f2691e4feb Change model to model ID in the Tractive DeviceInfo (#171147) 2026-05-18 23:12:24 +02:00
Thomas D f9654e15a6 Support stepper output in Qbus integration (#170772) 2026-05-18 22:44:16 +02:00
Michael 01dde25ffa Bump aioimmich to 0.14.1 (#171138) 2026-05-18 22:25:51 +02:00
Michael 34254c138f Fix handling of tracked devices on cleanup in FRITZ!Box Tools (#170574)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-18 22:03:43 +02:00
renovate[bot] 1076d65c9c Update syrupy to 5.2.0 (#171100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 22:00:11 +02:00
Heikki Henriksen ad71e31bad prusalink: add sd_ready, farm_mode, and status_connect binary sensors (#169310)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:56:52 +02:00
Franck Nijhof 7608d5f99d Fix WeatherFlow websocket crash when data payload is None (#171037) 2026-05-18 15:43:42 -04:00
Erik Montnemery cafcbf8179 Improve bluetooth test fixture (#171061) 2026-05-18 21:17:50 +02:00
Erik Montnemery 852faa7f95 Fix docstring of cv.string (#171128) 2026-05-18 21:14:46 +02:00
renovate[bot] 5cf1e185f0 Update ruff (#171118)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robert Resch <robert@resch.dev>
2026-05-18 20:57:05 +02:00
Glenn Waters c4d25a5a26 ElkM1 integration: Deprecate Elk Setting sensors; replaced by time/number entities (#170041) 2026-05-18 20:56:29 +02:00
Maciej Bieniek 18f8e11865 Split Tractive entities into tracker-related and pet-related (#170256) 2026-05-18 20:55:05 +02:00
Kamil Breguła e8f3d357c4 Add group support to WLED main light (#169669)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 20:44:34 +02:00
Michael Hansen 1ad81697f7 Add chat log and response rendering to Wyoming conversation (#170433) 2026-05-18 20:43:33 +02:00
Arie Catsman f66652c729 Provide request retry option to overcome intermittant enphase_envoy failures (#168222)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-18 20:40:14 +02:00
Robert Resch c468ae77f3 Enable agentic library workflow on forks and users without write rightsA (#171123) 2026-05-18 20:20:36 +02:00
renovate[bot] 251d7e15d2 Update requests to 2.34.2 (#171119) 2026-05-18 20:18:53 +02:00
Sören d268f8b486 Restore Avea brightness on turn on (#171120) 2026-05-18 19:58:59 +02:00
Jamin 6f3dfab487 Voip runtime data (#170765)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-18 19:40:30 +02:00
Crocmagnon 8d8b9bb2e8 data grand lyon: split coordinators (#170662) 2026-05-18 19:30:55 +02:00
Franck Nijhof 8c9d659dcf Use HA timezone for date in recollect_waste (#171106) 2026-05-18 19:20:14 +02:00
Franck Nijhof f08adfe712 Use HA timezone for date in cookidoo (#171109) 2026-05-18 19:18:30 +02:00
renovate[bot] de29414b37 Update uv to 0.11.14 (#171099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 19:13:07 +02:00
Ludovic BOUÉ 01d9c2e810 Add siren platform support to Matter integration (#170031)
Co-authored-by: Ludovic BOUÉ <938089+lboue@users.noreply.github.com>
2026-05-18 18:45:28 +02:00
epenet 9b3b3eca6d Prioritize native Tuya unit of measurement (#170338) 2026-05-18 17:29:46 +02:00
Copilot 2e45ce36a7 Create agentic workflow to validate dependencies (#168855)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-05-18 16:59:49 +02:00
g4bri3lDev fe56ce6813 Bump py-opendisplay to 7.0.0 (#171088) 2026-05-18 16:50:51 +02:00
Erik Montnemery 8000b419ea Remove stale reference to advanced mode from MQTT tests (#171095) 2026-05-18 16:14:52 +02:00
Noah Husby f0a5ce747e Disallow session closure for Cambridge Audio (#171036) 2026-05-18 15:47:49 +02:00
aide 7da5b10b51 Add new integration for AiDot (#167272)
Co-authored-by: bryan <185078974@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-18 14:57:25 +02:00
Mick Vleeshouwer 94b373641d Fix tilt and position support for VenetianBlind covers in Overkiz (#170974) 2026-05-18 14:51:26 +02:00
Pete Sage dfd241dd1a Add search to Sonos (#170891)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 14:39:12 +02:00
Klaas Schoute 27b161bf7c Add new params to actions of easyEnergy integration (#169225)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-18 14:36:01 +02:00
Josef Zweck f2362aa2a3 Bump pylamarzocco to 2.2.5 (#171083) 2026-05-18 14:16:04 +02:00
Matthias Alphart 90946c3e2f Fix swallowed exception in knx event_register action (#171010)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 14:06:25 +02:00
Franck Nijhof 318091689c Fix line length violations in new code since cleanup PRs (#171062) 2026-05-18 14:03:52 +02:00
Petro31 ee8c3ca864 Fix swallowed exceptions in template action handlers (#171080) 2026-05-18 13:55:59 +02:00
Jonathan Segev 5f6f300a20 Bump aiolyric to 2.1.0 (#171007)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-18 13:54:33 +02:00
Sören ad04aeced9 Fix Avea color state refresh (#171003) 2026-05-18 13:54:08 +02:00
Franck Nijhof bbb31f2910 Group sequential executor jobs in verisure config flow (#171081) 2026-05-18 13:47:58 +02:00
Martin Hjelmare 0ed81e426b Fix swallowed exceptions in VLC Telnet actions (#171071) 2026-05-18 13:42:12 +02:00
Mick Vleeshouwer 4582c56c1c Fix is_closed state and position for DynamicPergola covers in Overkiz (#170983) 2026-05-18 13:41:28 +02:00
Mick Vleeshouwer 9ce3e00e87 Fix is_closed state for DiscretePositionableGarageDoor in Overkiz (#170981) 2026-05-18 13:40:48 +02:00
Franck Nijhof bd2ea9a148 Group sequential executor jobs in roomba vacuum (#171078) 2026-05-18 13:39:00 +02:00
Paulus Schoutsen e34be91439 Bump dsmr-parser to 1.7.0 (#171082)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 13:36:32 +02:00
Franck Nijhof 3e5beb9aa3 Group sequential executor jobs in ezviz config flow (#171084) 2026-05-18 13:33:11 +02:00
Franck Nijhof ac5df83d1a Group sequential executor jobs in comfoconnect fan (#171085) 2026-05-18 13:30:49 +02:00
Franck Nijhof c9e014c5d8 Group sequential executor jobs in soma setup (#171087) 2026-05-18 13:29:42 +02:00
Franck Nijhof 1b7564dcdf Group sequential executor jobs in smappee config flow (#171086) 2026-05-18 13:29:00 +02:00
455 changed files with 14057 additions and 2183 deletions
+1 -1
View File
@@ -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
+3
View File
@@ -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/**"
+26
View File
@@ -6,6 +6,7 @@
"pep621",
"pip_requirements",
"pre-commit",
"dockerfile",
"custom.regex",
"homeassistant-manifest"
],
@@ -21,6 +22,10 @@
]
},
"dockerfile": {
"managerFilePatterns": ["/^Dockerfile$/"]
},
"homeassistant-manifest": {
"managerFilePatterns": [
"/^homeassistant/components/[^/]+/manifest\\.json$/"
@@ -35,6 +40,14 @@
"matchStrings": ["required-version = \">=(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ruff",
"datasourceTemplate": "pypi"
},
{
"customType": "regex",
"description": "Update go2rtc RECOMMENDED_VERSION in const.py alongside the Dockerfile pin",
"managerFilePatterns": ["/^homeassistant/components/go2rtc/const\\.py$/"],
"matchStrings": ["RECOMMENDED_VERSION = \"(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ghcr.io/alexxit/go2rtc",
"datasourceTemplate": "docker"
}
],
@@ -184,6 +197,13 @@
"enabled": true,
"labels": ["dependency"]
},
{
"description": "Docker allowlist (ghcr.io exposes no release timestamps so the global cooldown needs to be bypassed)",
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
"enabled": true,
"minimumReleaseAge": null,
"labels": ["dependency"]
},
{
"description": "Group ruff pre-commit hook with its PyPI twin into one PR",
"matchPackageNames": ["astral-sh/ruff-pre-commit", "ruff"],
@@ -213,6 +233,12 @@
"matchPackageNames": ["pylint", "astroid"],
"groupName": "pylint",
"groupSlug": "pylint"
},
{
"description": "Group go2rtc Dockerfile pin with const.py RECOMMENDED_VERSION into one PR",
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
"groupName": "go2rtc",
"groupSlug": "go2rtc"
}
]
}
File diff suppressed because it is too large Load Diff
+405
View File
@@ -0,0 +1,405 @@
---
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "requirements*.txt"
- "homeassistant/package_constraints.txt"
- "pyproject.toml"
forks: ["*"]
workflow_dispatch:
inputs:
pull_request_number:
description: "Pull request number to (re-)check"
required: true
type: number
roles: all
permissions:
contents: read
pull-requests: read
issues: read
network:
allowed:
- python
tools:
web-fetch: {}
github:
toolsets: [default]
safe-outputs:
add-comment:
max: 1
description: >
Checks changed Python package requirements on PRs targeting the core repo
(including PRs opened from forks) and verifies licenses match PyPI metadata, source
repositories are publicly accessible, PyPI releases were uploaded via
automated CI (Trusted Publisher attestation), the package's release pipeline
uses OIDC or equivalent automated credentials (not static tokens), and the PR
description contains the required links.
---
# Requirements License and Availability Check
You are a code review assistant for the Home Assistant project. Your job is to
review changes to Python package requirements and verify they meet the project's
standards.
## Context
- Home Assistant uses `requirements_all.txt` (all integration packages),
`requirements.txt` (core packages), `requirements_test.txt` (test
dependencies), and `requirements_test_all.txt` (all test dependencies) to
declare Python dependencies.
- Each integration lists its packages in `homeassistant/components/<name>/manifest.json`
under the `requirements` field.
- Allowed licenses are maintained in `script/licenses.py` under
`OSI_APPROVED_LICENSES_SPDX` (SPDX identifiers) and `OSI_APPROVED_LICENSES`
(classifier strings).
## Step 1 — Identify Changed Packages
Use the GitHub tool to fetch the PR diff. Look for lines that were added (`+`)
or removed (`-`) in **any** of these files:
- `requirements.txt`
- `requirements_all.txt`
- `requirements_test.txt`
- `requirements_test_all.txt`
- `homeassistant/package_constraints.txt`
- `pyproject.toml`
For each changed line that contains a package pin (e.g. `SomePackage==1.2.3`),
classify it as:
- **New package**: the package name appears only in `+` lines, with no
corresponding `-` line for the same package name.
- **Version bump**: the same package name appears in both `+` lines (new
version) and `-` lines (old version), with different version numbers.
Record the **old version** and **new version** for every version bump — you
will need these values in Step 4.
## Step 2 — Check License via PyPI
For each new or bumped package:
1. Fetch `https://pypi.org/pypi/{package_name}/json` (use the exact
package name as it appears on the requirements file).
2. From the JSON response, extract:
- `info.license` — free-text license field
- `info.license_expression` — SPDX expression (if present)
- `info.classifiers` — filter for entries starting with `"License ::"`,
then normalize each match the same way as `script/licenses.py` by
extracting the final ` :: ` segment (for example,
`"License :: OSI Approved :: MIT License"``"MIT License"`).
3. Determine if the license is in the approved list from `script/licenses.py`:
- SPDX identifiers: compare against `OSI_APPROVED_LICENSES_SPDX`
- Normalized classifier strings: compare against `OSI_APPROVED_LICENSES`
4. Flag a package as ❌ if the license is unknown, missing, or not in the
approved list. Flag as ⚠️ if the license information is ambiguous or cannot
be definitively determined.
## Step 2b — Verify PyPI Release Was Uploaded by CI
For each new or bumped package, verify that the release on PyPI was published
automatically by a CI pipeline (via OIDC Trusted Publisher), not uploaded
manually.
1. Fetch the PyPI JSON for the specific version being introduced or bumped:
`https://pypi.org/pypi/{package_name}/{version}/json`
2. Inspect the `urls` array in the response. For each distribution file (wheel
or sdist), note the filename.
3. For each filename, attempt to fetch the PyPI provenance attestation:
`https://pypi.org/integrity/{package_name}/{version}/{filename}/provenance`
- If the response is HTTP 200 and contains a valid attestation object,
inspect `attestation_bundles[*].publisher`. A Trusted Publisher attestation
will have a `kind` identifying the CI system (e.g. `"GitHub Actions"`,
`"GitLab"`) and a `repository` or `project` field matching the source
repository.
- If at least one distribution file has a valid Trusted Publisher attestation,
mark ✅ CI-uploaded.
- If no attestation is found for any file (404 for all), mark ❌ — "Release
has no provenance attestation; it may have been uploaded manually".
- If an attestation exists but the `publisher` does not identify a recognized
CI system or Trusted Publisher, mark ⚠️ — "Attestation present but
publisher cannot be verified as automated CI".
Note: if PyPI returns an error fetching the per-version JSON, fall back to the
latest JSON (`https://pypi.org/pypi/{package_name}/json`) and look up the
specific version in the `releases` dict.
## Step 3 — Identify Repository URL
For each new or bumped package:
1. From the PyPI JSON at `info.project_urls`, find the source repository URL
(keys such as `"Source"`, `"Homepage"`, `"Repository"`, or `"Source Code"`).
2. Record that repository URL for later checks.
3. If no suitable repository URL is present, mark ❌ with a note that the
source repository URL is missing and cannot be verified.
## Step 4 — Check PR Description
Read the PR body from the GitHub API using the PR number from the workflow
context (`pull-request-number`). If that value is absent, use the
`workflow_dispatch` input `pull_request_number`.
Extract all URLs present in the PR body.
### 4a — New packages: repository link required
For **new packages** (brand-new dependency not previously in any requirements
file): the PR description must contain a link that points to the package's
**source repository** as identified in Step 3 (the URL recorded from
`info.project_urls`). A PyPI page link alone is **not** acceptable — the link
must point directly to the source repository (e.g. a GitHub or GitLab URL).
- If a URL in the PR body matches (or is a sub-path of) the source repository
URL identified via PyPI, mark ✅.
- If the PR body contains a source repository URL that does **not** match the
repository URL found in the package's PyPI metadata (`info.project_urls`),
mark ❌ — "PR description links to `<pr_url>` but PyPI reports the source
repository as `<pypi_repo_url>`; please use the correct repository URL."
- If no source repository URL is present in the PR body at all, mark ❌ —
"PR description must link to the source repository at `<repo_url>` (found
via PyPI). A PyPI page link is not sufficient."
### 4b — Version bumps: changelog or diff link required
For **version bumps**: the PR description must contain a link to a changelog,
release notes page, or a diff/comparison URL that references the **correct
versions** being bumped (old → new).
Checks to perform for each bumped package (old version = X, new version = Y):
1. Extract all URLs from the PR body that contain the repository's domain or
path (as identified in Step 3).
2. Verify that at least one such URL includes both the old version string and
new version string in some form — e.g. a GitHub compare URL like
`compare/vX...vY`, a releases URL mentioning version Y, or a
`CHANGELOG.md` anchor referencing Y.
3. If no URL matches, check if the PR body contains any changelog/diff link at
all for this package.
Outcome:
- ✅ — a URL pointing to the correct repo with version references covering the
exact bump (X → Y).
- ⚠️ — a changelog/diff link exists but does not clearly reference the correct
versions or the correct repository; explain what was found and what is
expected.
- ❌ — no changelog or diff link found at all in the PR description for this
package.
### 4c — Diff consistency check
For each **version bump**, verify that the version change recorded in the diff
(Step 1) is internally consistent:
- The `-` line must contain the old version and the `+` line must contain the
new version for the same package name.
- Flag ❌ if the diff shows a downgrade (new version < old version) without an
explanation, or if the version strings cannot be parsed.
## Step 5 — Verify Source Repository is Publicly Accessible
Before inspecting the release pipeline, confirm that the source repository
identified in Step 3 is publicly reachable.
For each new or bumped package:
1. Use the source repository URL recorded in Step 3.
2. If no repository URL was found in `info.project_urls`, mark ❌ — "No source
repository URL found in PyPI metadata; a public source repository is
required."
3. If a repository URL was found, perform a GET request to that URL (using
web-fetch). If the response is HTTP 200 and returns a publicly accessible
page (not a login redirect or error page), mark ✅.
4. If the response is non-200, the URL redirects to a login/authentication page,
or the repository appears private or unavailable, mark ❌ — "Source
repository at `<repo_url>` is not publicly accessible. Home Assistant
requires all dependencies to have publicly available source code." **Do not
proceed with the release pipeline check (Step 6) for this package.**
## Step 6 — Check Release Pipeline Sanity
For each new or bumped package, determine the source repository host from the
URL identified in Step 3, then inspect whether the project's release/publish CI
workflow is sane. The checks differ by hosting provider.
### GitHub repositories (`github.com`)
1. Using the GitHub API, list the workflows in the source repository:
`GET /repos/{owner}/{repo}/actions/workflows`
2. Identify any workflow whose name or filename suggests publishing to PyPI
(e.g., contains "release", "publish", "pypi", or "deploy").
3. Fetch the workflow file content and check the following:
a. **Trigger sanity**: The publish job should be triggered by `push` to tags,
`release: published`, or `workflow_run` on a release job — **not** solely
by `workflow_dispatch` with no additional guards. A `workflow_dispatch`
trigger alongside other triggers is acceptable. Mark ❌ if the only trigger
is manual `workflow_dispatch` with no environment protection rules.
b. **OIDC / Trusted Publisher**: The workflow should use OIDC-based publishing.
Look for `id-token: write` permission and one of:
- `pypa/gh-action-pypi-publish` action
- `actions/attest-build-provenance` action
- Any step that sets `TWINE_PASSWORD` from `secrets.PYPI_TOKEN` directly
(flag ❌ if a long-lived API token is used instead of OIDC).
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined,
❌ if a static secret token is the only credential.
c. **No manual upload bypass**: Verify there is no step that calls
`twine upload` or `pip upload` outside of a properly gated job (e.g., one
that requires an environment approval). Flag ⚠️ if such steps exist.
4. If no publish workflow is found in the repository, mark ⚠️ — "No publish
workflow found; it is unclear how this package is released to PyPI."
### GitLab repositories (`gitlab.com` or self-hosted GitLab)
1. Use the GitLab REST API to list CI/CD pipeline configuration files. First
resolve the project ID via
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`
and note the `id` field.
2. Fetch the repository's `.gitlab-ci.yml` (and any included files) using
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`
(use web-fetch for public repos).
3. Identify any job whose name or `stage` suggests publishing to PyPI
(e.g., "publish", "deploy", "release", "pypi").
4. For each such job, check:
a. **Trigger sanity**: The job should run only on tag pipelines (`only: tags`
or `rules: - if: $CI_COMMIT_TAG`) or on protected branches — **not**
solely on manual triggers (`when: manual`) with no additional protection.
Mark ❌ if the only trigger is manual with no environment or protected-branch
guard.
b. **Automated credentials**: The job should use GitLab's OIDC ID token
(`id_tokens:` block) and `pypa/gh-action-pypi-publish` equivalent, or
reference `secrets.PYPI_TOKEN` / `$PYPI_TOKEN` injected from GitLab CI/CD
protected variables (flag ❌ if the token is hard-coded or unprotected).
Mark ✅ if OIDC or protected CI variables are used, ⚠️ if the method
cannot be determined, ❌ if credentials appear to be insecure.
c. **No manual upload bypass**: Flag ⚠️ if any job calls `twine upload`
without being behind a protected-variable or environment guard.
5. If no publish job is found, mark ⚠️ — "No publish job found in .gitlab-ci.yml;
it is unclear how this package is released to PyPI."
### Other code hosting providers
For repositories hosted on platforms other than GitHub or GitLab (e.g.,
Bitbucket, Codeberg, Gitea, Sourcehut):
1. Use web-fetch to retrieve the repository's root page and look for any
publicly visible CI configuration files (e.g., `.circleci/config.yml`,
`Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`,
`.builds/*.yml` for Sourcehut).
2. Apply the same conceptual checks as above:
- Does publishing run on automated triggers (tags/releases), not solely
manual ones?
- Are credentials injected by the CI system (not hard-coded)?
- Is there a `twine upload` or equivalent step that could be run manually?
3. If no CI configuration can be retrieved, mark ⚠️ — "Release pipeline could
not be inspected; hosting provider is not GitHub or GitLab."
## Step 7 — Post a Review Comment
**Always** post a review comment using `add_comment`, regardless of whether
packages pass or fail. Use the following structure:
**Note on deduplication**: The workflow automatically updates any previous
requirements-check comment on the PR in place (preserving its position in the
thread). If no previous comment exists, the newly created comment is kept as-is.
You do not need to search for or update previous comments yourself.
### Comment structure
Begin every comment with the HTML marker `<!-- requirements-check -->` on its
own line (this is used by the workflow to find the previous comment and update
it on the next run).
### 7a — Overall summary line
Begin the comment with a single summary line, before anything else:
- If everything passed: `All requirements checks passed. ✅`
- If there are failures or warnings: `⚠️ Some checks require attention — see the details below.`
### 7b — Summary table
Render a compact table where every check column contains **only the status
icon** (✅, ⚠️, or ❌). No explanatory text belongs inside the table cells —
all detail goes in the per-package sections below.
Use `—` (em dash) when a check was skipped (e.g. Release Pipeline is skipped
when the repository is not publicly accessible).
```
<!-- requirements-check -->
## Requirements Check
| Package | Type | Old→New | License | Repo Public | CI Upload | Release Pipeline | PR Link | Diff Consistent |
|---------|------|---------|---------|-------------|-----------|------------------|---------|-----------------|
| PackageA | bump | 1.2.3→1.3.0 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| PackageB | new | —→4.5.6 | ❌ | ✅ | ❌ | ⚠️ | ❌ | ✅ |
| PackageC | bump | 2.0.0→2.1.0 | ✅ | ❌ | — | — | ⚠️ | ✅ |
```
### 7c — Per-package detail sections
After the table, add one collapsible `<details>` block per package.
- If **all checks passed** for that package, render the block **collapsed**
(no `open` attribute) so the comment stays concise.
- If **any check failed or produced a warning**, render the block **open**
(`<details open>`) so the contributor sees the issues immediately.
Each block must include the full detail for every check: the license found, the
repository URL, whether a provenance attestation was found, the release
pipeline findings, the PR link found (or missing), and whether the diff is
consistent. For failed or warned checks, explain exactly what the contributor
must fix, including the expected source repository URL, expected version range,
etc.
Template (repeat for each package):
```
<details open>
<summary><strong>PackageB 📦 new —→4.5.6</strong></summary>
- **License**: ❌ License is `UNKNOWN` — not in the approved list. Check PyPI metadata and `script/licenses.py`.
- **Repository Public**: ✅ https://github.com/example/packageb is publicly accessible.
- **CI Upload**: ❌ No provenance attestation found for any distribution file. The release may have been uploaded manually.
- **Release Pipeline**: ⚠️ No publish workflow found in the repository; it is unclear how this package is released to PyPI.
- **PR Link**: ❌ PR description must link to the source repository at https://github.com/example/packageb (a PyPI page link is not sufficient).
- **Diff Consistent**: ✅
</details>
```
Collapsed example (all checks passed):
```
<details>
<summary><strong>PackageA 📦 bump 1.2.3→1.3.0</strong></summary>
- **License**: ✅ MIT
- **Repository Public**: ✅ https://github.com/example/packagea
- **CI Upload**: ✅ Trusted Publisher attestation found (GitHub Actions).
- **Release Pipeline**: ✅ OIDC via `pypa/gh-action-pypi-publish`; triggered on `release: published`; `environment: release` gate.
- **PR Link**: ✅ https://github.com/example/packagea/compare/v1.2.3...v1.3.0
- **Diff Consistent**: ✅
</details>
```
## Notes
- Be constructive and helpful. Provide direct links where possible so the
contributor can quickly fix the issue.
- If PyPI returns an error for a package, mention that it could not be found and
suggest the contributor verify the package name.
- For packages that only appear in `homeassistant/package_constraints.txt` or
`pyproject.toml` without being tied to a specific integration, the PR
description link requirement still applies.
- When checking test-only packages (from `requirements_test.txt` or
`requirements_test_all.txt`), apply the same license, repository, and PR
description checks as for production dependencies.
- A package that appears in both a production file and a test file should only
be reported once; use the production file entry as the canonical one.
- This workflow is only triggered when a commit actually changes one of the
tracked requirements files (for `synchronize` events GitHub compares the
before/after SHAs of the push, not the entire PR diff). Members can manually
retrigger the workflow via `workflow_dispatch` with the PR number to re-run
the check after updating the PR description or fixing issues without changing
any requirements files. On a retrigger the existing comment is updated in
place so there is always exactly one requirements-check comment in the PR.
+3 -1
View File
@@ -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
View File
@@ -1,5 +1,6 @@
ignore: |
tests/fixtures/core/config/yaml_errors/
.github/workflows/*.lock.yml
rules:
braces:
level: error
Generated
+4
View File
@@ -68,6 +68,8 @@ CLAUDE.md @home-assistant/core
/tests/components/agent_dvr/ @ispysoftware
/homeassistant/components/ai_task/ @home-assistant/core
/tests/components/ai_task/ @home-assistant/core
/homeassistant/components/aidot/ @s1eedz @HongBryan
/tests/components/aidot/ @s1eedz @HongBryan
/homeassistant/components/air_quality/ @home-assistant/core
/tests/components/air_quality/ @home-assistant/core
/homeassistant/components/airgradient/ @airgradienthq @joostlek
@@ -2054,6 +2056,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/yi/ @bachya
/homeassistant/components/yolink/ @matrixd2
/tests/components/yolink/ @matrixd2
/homeassistant/components/yoto/ @cdnninja @piitaya
/tests/components/yoto/ @cdnninja @piitaya
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
+2 -2
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
# Automatically generated by hassfest.
# Partly generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
@@ -26,7 +26,7 @@ WORKDIR /usr/src
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc:1.9.14@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
## Setup Home Assistant Core dependencies
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
@@ -0,0 +1,25 @@
"""The aidot integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AidotConfigEntry, AidotDeviceManagerCoordinator
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
"""Set up aidot from a config entry."""
coordinator = AidotDeviceManagerCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
return True
async def async_unload_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.async_cleanup()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,66 @@
"""Config flow for Aidot integration."""
from typing import Any
from aidot.client import AidotClient
from aidot.const import CONF_ID, DEFAULT_COUNTRY_CODE, SUPPORTED_COUNTRY_CODES
from aidot.exceptions import AidotUserOrPassIncorrect
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(
CONF_COUNTRY_CODE,
default=DEFAULT_COUNTRY_CODE,
): selector.CountrySelector(
selector.CountrySelectorConfig(
countries=SUPPORTED_COUNTRY_CODES,
)
),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AidotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle aidot config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
client = AidotClient(
session=async_get_clientsession(self.hass),
country_code=user_input[CONF_COUNTRY_CODE],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
try:
login_info = await client.async_post_login()
except AidotUserOrPassIncorrect:
errors["base"] = "invalid_auth"
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
if not errors:
await self.async_set_unique_id(login_info[CONF_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{user_input[CONF_USERNAME]} {user_input[CONF_COUNTRY_CODE]}",
data=login_info,
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
+3
View File
@@ -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
)
+122
View File
@@ -0,0 +1,122 @@
"""Support for Aidot lights."""
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGBW_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AidotConfigEntry, AidotDeviceUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: AidotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Light."""
coordinator = entry.runtime_data
async_add_entities(
AidotLight(device_coordinator)
for device_coordinator in coordinator.device_coordinators.values()
)
class AidotLight(CoordinatorEntity[AidotDeviceUpdateCoordinator], LightEntity):
"""Representation of a Aidot Wi-Fi Light."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, coordinator: AidotDeviceUpdateCoordinator) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.device_client.info.dev_id
if hasattr(coordinator.device_client.info, "cct_max"):
self._attr_max_color_temp_kelvin = coordinator.device_client.info.cct_max
if hasattr(coordinator.device_client.info, "cct_min"):
self._attr_min_color_temp_kelvin = coordinator.device_client.info.cct_min
model_id = coordinator.device_client.info.model_id
manufacturer = model_id.split(".")[0]
model = model_id[len(manufacturer) + 1 :]
mac = coordinator.device_client.info.mac
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer=manufacturer,
model=model,
name=coordinator.device_client.info.name,
hw_version=coordinator.device_client.info.hw_version,
)
if coordinator.device_client.info.enable_rgbw:
self._attr_color_mode = ColorMode.RGBW
self._attr_supported_color_modes = {ColorMode.RGBW, ColorMode.COLOR_TEMP}
elif coordinator.device_client.info.enable_cct:
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
else:
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._update_status()
def _update_status(self) -> None:
"""Update light status from coordinator data."""
self._attr_is_on = self.coordinator.data.on
self._attr_brightness = self.coordinator.data.dimming
self._attr_color_temp_kelvin = self.coordinator.data.cct
self._attr_rgbw_color = self.coordinator.data.rgbw
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.online
@callback
def _handle_coordinator_update(self) -> None:
"""Update."""
self._update_status()
super()._handle_coordinator_update()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on, applying brightness, color temperature, RGBW, or plain on."""
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
await self.coordinator.device_client.async_set_brightness(brightness)
self.coordinator.data.dimming = brightness
self._attr_brightness = brightness
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
await self.coordinator.device_client.async_set_cct(color_temp_kelvin)
self.coordinator.data.cct = color_temp_kelvin
self._attr_color_temp_kelvin = color_temp_kelvin
self._attr_color_mode = ColorMode.COLOR_TEMP
elif ATTR_RGBW_COLOR in kwargs:
rgbw_color = kwargs.get(ATTR_RGBW_COLOR)
await self.coordinator.device_client.async_set_rgbw(rgbw_color)
self.coordinator.data.rgbw = rgbw_color
self._attr_rgbw_color = rgbw_color
self._attr_color_mode = ColorMode.RGBW
else:
await self.coordinator.device_client.async_turn_on()
self.coordinator.data.on = True
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self.coordinator.device_client.async_turn_off()
self.coordinator.data.on = False
self._attr_is_on = False
self.async_write_ha_state()
@@ -0,0 +1,11 @@
{
"domain": "aidot",
"name": "AiDot",
"codeowners": ["@s1eedz", "@HongBryan"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aidot",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-aidot==0.3.53"]
}
@@ -0,0 +1,67 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not register any events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
entity-disabled-by-default: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,25 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"user": {
"data": {
"country_code": "Country",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"country_code": "The country selected by AiDot app when logging in",
"password": "Password for logging in through AiDot app",
"username": "Account logged in through AiDot app"
}
}
}
}
}
@@ -26,8 +26,7 @@ from homeassistant.const import (
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
NumberSelector,
@@ -50,6 +49,8 @@ from .const import (
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_FETCH,
CONF_WEB_FETCH_MAX_USES,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -452,11 +453,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): int,
): cv.positive_int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
vol.Optional(
CONF_WEB_FETCH,
default=DEFAULT[CONF_WEB_FETCH],
): bool,
vol.Optional(
CONF_WEB_FETCH_MAX_USES,
default=DEFAULT[CONF_WEB_FETCH_MAX_USES],
): cv.positive_int,
}
)
@@ -18,6 +18,8 @@ CONF_PROMPT_CACHING = "prompt_caching"
CONF_THINKING_BUDGET = "thinking_budget"
CONF_THINKING_EFFORT = "thinking_effort"
CONF_TOOL_SEARCH = "tool_search"
CONF_WEB_FETCH = "web_fetch"
CONF_WEB_FETCH_MAX_USES = "web_fetch_max_uses"
CONF_WEB_SEARCH = "web_search"
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
@@ -45,6 +47,8 @@ DEFAULT = {
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
CONF_WEB_FETCH: False,
CONF_WEB_FETCH_MAX_USES: 5,
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_USER_LOCATION: False,
CONF_WEB_SEARCH_MAX_USES: 5,
+55 -17
View File
@@ -17,8 +17,6 @@ from anthropic.types import (
Base64PDFSourceParam,
BashCodeExecutionToolResultBlock,
CitationsDelta,
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockContent,
@@ -70,6 +68,9 @@ from anthropic.types import (
ToolUseBlock,
ToolUseBlockParam,
Usage,
WebFetchTool20250910Param,
WebFetchTool20260209Param,
WebFetchToolResultBlock,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
@@ -97,6 +98,12 @@ from anthropic.types.tool_search_tool_result_block_param import (
Content as ToolSearchToolResultBlockParamContentParam,
)
from anthropic.types.tool_use_block import Caller
from anthropic.types.web_fetch_tool_result_block import (
Content as WebFetchToolResultBlockContent,
)
from anthropic.types.web_fetch_tool_result_block_param import (
Content as WebFetchToolResultBlockParamContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -118,6 +125,8 @@ from .const import (
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_FETCH,
CONF_WEB_FETCH_MAX_USES,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -208,17 +217,9 @@ class ContentDetails:
"""Add a citation to the current detail."""
if not self.citation_details:
self.citation_details.append(CitationDetails())
citation_param: TextCitationParam | None = None
if isinstance(citation, CitationsWebSearchResultLocation):
citation_param = CitationWebSearchResultLocationParam(
type="web_search_result_location",
title=citation.title,
url=citation.url,
cited_text=citation.cited_text,
encrypted_index=citation.encrypted_index,
)
if citation_param:
self.citation_details[-1].citations.append(citation_param)
self.citation_details[-1].citations.append(
cast(TextCitationParam, citation.to_dict())
)
def delete_empty(self) -> None:
"""Delete empty citation details."""
@@ -289,6 +290,15 @@ def _convert_content( # noqa: C901
content.tool_result,
),
}
elif content.tool_name == "web_fetch":
tool_result_block = {
"type": "web_fetch_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
WebFetchToolResultBlockParamContentParam,
content.tool_result,
),
}
else:
tool_result_block = {
"type": "tool_result",
@@ -415,6 +425,7 @@ def _convert_content( # noqa: C901
id=tool_call.id,
name=cast(
Literal[
"web_fetch",
"web_search",
"code_execution",
"bash_code_execution",
@@ -428,6 +439,7 @@ def _convert_content( # noqa: C901
if tool_call.external
and tool_call.tool_name
in [
"web_fetch",
"web_search",
"code_execution",
"bash_code_execution",
@@ -609,6 +621,7 @@ class AnthropicDeltaStream:
if isinstance(
content_block,
(
WebFetchToolResultBlock,
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
@@ -724,13 +737,15 @@ class AnthropicDeltaStream:
self,
tool_use_id: str,
tool_name: Literal[
"web_fetch_tool_result",
"web_search_tool_result",
"code_execution_tool_result",
"bash_code_execution_tool_result",
"text_editor_code_execution_tool_result",
"tool_search_tool_result",
],
content: WebSearchToolResultBlockContent
content: WebFetchToolResultBlockContent
| WebSearchToolResultBlockContent
| CodeExecutionToolResultBlockContent
| BashCodeExecutionToolResultBlockContent
| TextEditorCodeExecutionToolResultBlockContent
@@ -907,6 +922,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"GetLiveContext",
"code_execution",
"web_search",
"web_fetch",
]
system = chat_log.content[0]
@@ -980,12 +996,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
]
if options[CONF_CODE_EXECUTION]:
# The `web_search_20260209` tool automatically enables
# `code_execution_20260120` tool
# The `web_search_20260209` and `web_fetch_20260209` tools
# automatically enable `code_execution_20260120` tool
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options[CONF_WEB_SEARCH]
or (not options[CONF_WEB_SEARCH] and not options[CONF_WEB_FETCH])
):
tools.append(
CodeExecutionTool20250825Param(
@@ -1023,6 +1039,28 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
}
tools.append(web_search)
if options[CONF_WEB_FETCH]:
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options[CONF_CODE_EXECUTION]
):
tools.append(
WebFetchTool20250910Param(
name="web_fetch",
type="web_fetch_20250910",
max_uses=options[CONF_WEB_FETCH_MAX_USES],
)
)
else:
tools.append(
WebFetchTool20260209Param(
name="web_fetch",
type="web_fetch_20260209",
max_uses=options[CONF_WEB_FETCH_MAX_USES],
)
)
# Handle attachments by adding them to the last user message
last_content = chat_log.content[-1]
if last_content.role == "user" and last_content.attachments:
@@ -80,6 +80,8 @@
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
"web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch%]",
"web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch_max_uses%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
},
@@ -90,6 +92,8 @@
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
"web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch%]",
"web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch_max_uses%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
},
@@ -149,6 +153,8 @@
"thinking_effort": "Thinking effort",
"tool_search": "Enable tool search tool",
"user_location": "Include home location",
"web_fetch": "Enable web fetch",
"web_fetch_max_uses": "Maximum web fetches",
"web_search": "Enable web search",
"web_search_max_uses": "Maximum web searches"
},
@@ -159,6 +165,8 @@
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
"user_location": "Localize search results based on home location",
"web_fetch": "The web fetch tool allows Claude to retrieve full content from specified web pages and PDF documents to augment Claude's context with live web content",
"web_fetch_max_uses": "Limit the number of web fetches performed per response",
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
"web_search_max_uses": "Limit the number of searches performed per response"
},
@@ -5,7 +5,7 @@ from pyatv.interface import AppleTV, KeyboardListener
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -21,23 +21,33 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Apple TV binary sensor based on a config entry."""
# apple_tv config entries always have a unique id
manager = config_entry.runtime_data
cb: CALLBACK_TYPE
added = False
@callback
def setup_entities(atv: AppleTV) -> None:
nonlocal added
if added:
return
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
async_add_entities(
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
)
cb()
added = True
cb = async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
config_entry.async_on_unload(
async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
)
)
config_entry.async_on_unload(cb)
# The manager may have already connected (and dispatched SIGNAL_CONNECTED)
# before this platform was forwarded, in which case the signal above was
# missed; handle that case directly.
if manager.atv is not None:
setup_entities(manager.atv)
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
@@ -53,18 +53,19 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
self.state = State(client, zone)
self.update_in_progress = False
name = config_entry.title
device_name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id
unique_id_device = unique_id
if zone != 1:
unique_id_device += f"-{zone}"
name += f" Zone {zone}"
device_name += f" Zone {zone}"
self.device_name = device_name
self.device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id_device)},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=name,
name=device_name,
)
self.zone_unique_id = f"{unique_id}-{zone}"
@@ -1,11 +1,36 @@
"""Base entity for Arcam FMJ integration."""
from collections.abc import Callable, Coroutine
import functools
from typing import Any
from arcam.fmj import ConnectionFailed
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ArcamFmjCoordinator
def convert_exception[**_P, _R](
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
"""Convert a connection failure into a translated HomeAssistantError."""
@functools.wraps(func)
async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(*args, **kwargs)
except ConnectionFailed as exception:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="connection_failed"
) from exception
return _convert_exception
class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
"""Base entity for Arcam FMJ."""
@@ -1,11 +1,9 @@
"""Arcam media player."""
from collections.abc import Callable, Coroutine
import functools
import logging
from typing import Any
from arcam.fmj import ConnectionFailed, SourceCodes
from arcam.fmj import SourceCodes
from homeassistant.components.media_player import (
BrowseError,
@@ -18,12 +16,12 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from .entity import ArcamFmjEntity
from .entity import ArcamFmjEntity, convert_exception
_LOGGER = logging.getLogger(__name__)
@@ -41,23 +39,6 @@ async def async_setup_entry(
)
def convert_exception[**_P, _R](
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
"""Return decorator to convert a connection error into a home assistant error."""
@functools.wraps(func)
async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(*args, **kwargs)
except ConnectionFailed as exception:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="connection_failed"
) from exception
return _convert_exception
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
"""Representation of a media device."""
@@ -79,11 +60,17 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
@property
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
if self._state.get_power():
return MediaPlayerState.ON
return MediaPlayerState.OFF
def state(self) -> MediaPlayerState | None:
"""Return the state of the device.
``None`` is returned (surfaced as ``unknown``) when the device has
not yet reported a power state; this is distinct from a real
powered-off state and must not be collapsed to ``OFF``.
"""
power = self._state.get_power()
if power is None:
return None
return MediaPlayerState.ON if power else MediaPlayerState.OFF
@convert_exception
async def async_mute_volume(self, mute: bool) -> None:
@@ -179,7 +166,7 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
]
return BrowseMedia(
title="Arcam FMJ Receiver",
title=self.coordinator.device_name,
media_class=MediaClass.DIRECTORY,
media_content_id="root",
media_content_type="library",
+33 -5
View File
@@ -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(
@@ -176,25 +187,42 @@ class AveaLight(LightEntity):
self._light = light
self._attr_name = entry_title
self._attr_brightness = light.brightness
self._last_brightness = 255
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if not kwargs:
self._light.set_brightness(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)
+4 -2
View File
@@ -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
@@ -89,7 +89,9 @@ class PassiveBluetoothDataUpdateCoordinator(
class PassiveBluetoothCoordinatorEntity[ # pylint: disable=home-assistant-enforce-class-module
_PassiveBluetoothDataUpdateCoordinatorT: PassiveBluetoothDataUpdateCoordinator = PassiveBluetoothDataUpdateCoordinator
_PassiveBluetoothDataUpdateCoordinatorT: (
PassiveBluetoothDataUpdateCoordinator
) = PassiveBluetoothDataUpdateCoordinator
](BaseCoordinatorEntity[_PassiveBluetoothDataUpdateCoordinatorT]):
"""A class for entities using DataUpdateCoordinator."""
@@ -94,8 +94,8 @@ def serialize_service_info(
"address": service_info.address,
"rssi": service_info.rssi,
"manufacturer_data": {
str(manufacturer_id): manufacturer_data.hex()
for manufacturer_id, manufacturer_data in service_info.manufacturer_data.items()
str(manufacturer_id): data.hex()
for manufacturer_id, data in service_info.manufacturer_data.items()
},
"service_data": {
service_uuid: service_data.hex()
@@ -151,7 +151,9 @@ def sensor_update_to_bluetooth_data_update(
device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[
description.device_class
]
for device_key, description in sensor_update.binary_entity_descriptions.items()
for device_key, description in (
sensor_update.binary_entity_descriptions.items()
)
if description.device_class
},
entity_data={
+4 -2
View File
@@ -52,14 +52,16 @@ async def async_get_calendars(
warned_calendars.add((url, comp))
if comp in ASSUMED_COMPONENTS:
_LOGGER.warning(
"CalDAV server does not report supported components for calendar %s, "
"CalDAV server does not report supported"
" components for calendar %s, "
"assuming it supports the requested component '%s'",
name or url,
comp,
)
else:
_LOGGER.warning(
"CalDAV server does not report supported components for calendar %s. "
"CalDAV server does not report supported"
" components for calendar %s. "
"Not assuming support for requested component '%s'",
name or url,
comp,
@@ -31,7 +31,9 @@ async def async_setup_entry(
) -> bool:
"""Set up Cambridge Audio integration from a config entry."""
client = StreamMagicClient(entry.data[CONF_HOST], async_get_clientsession(hass))
client = StreamMagicClient(
entry.data[CONF_HOST], async_get_clientsession(hass), should_close_session=False
)
async def _connection_update_callback(
_client: StreamMagicClient, _callback_type: CallbackType
+40 -73
View File
@@ -8,7 +8,7 @@ from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.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
+14 -12
View File
@@ -24,25 +24,27 @@
}
},
"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 dont want to add all available cast devices.\nIgnore CEC - A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be passed to pychromecast.IGNORE_CEC.",
"title": "Advanced Google Cast configuration"
},
"basic_options": {
"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 dont want to add all available cast devices."
},
"name": "More options"
}
},
"title": "[%key:component::cast::config::step::config::title%]"
}
}
+5 -2
View File
@@ -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()
+6 -7
View File
@@ -94,13 +94,12 @@ class ComfoConnectFan(FanEntity):
self._handle_mode_update,
)
)
await self.hass.async_add_executor_job(
self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE
)
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self.hass.async_add_executor_job(
self._ccb.comfoconnect.register_sensor, SENSOR_OPERATING_MODE_BIS
)
def _register_sensors() -> None:
self._ccb.comfoconnect.register_sensor(SENSOR_FAN_SPEED_MODE)
self._ccb.comfoconnect.register_sensor(SENSOR_OPERATING_MODE_BIS)
await self.hass.async_add_executor_job(_register_sensors)
def _handle_speed_update(self, value: float) -> None:
"""Handle update callbacks."""
@@ -10,6 +10,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
@@ -58,7 +59,7 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
if not self.coordinator.data.week_plan:
return None
today = date.today() # noqa: DTZ011
today = dt_util.now().date()
for day_data in self.coordinator.data.week_plan:
day_date = date.fromisoformat(day_data.id)
if day_date >= today and day_data.recipes:
@@ -1,7 +1,7 @@
"""DataUpdateCoordinator for the Cookidoo integration."""
from dataclasses import dataclass
from datetime import date, timedelta
from datetime import timedelta
import logging
from cookidoo_api import (
@@ -21,6 +21,7 @@ from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -81,7 +82,9 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
ingredient_items = await self.cookidoo.get_ingredient_items()
additional_items = await self.cookidoo.get_additional_items()
subscription = await self.cookidoo.get_active_subscription()
week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today()) # noqa: DTZ011
week_plan = await self.cookidoo.get_recipes_in_calendar_week(
dt_util.now().date()
)
except CookidooAuthException:
try:
await self.cookidoo.refresh_token()
@@ -1,12 +1,20 @@
"""The Data Grand Lyon integration."""
import asyncio
from data_grand_lyon_ha import DataGrandLyonClient
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
from .coordinator import (
DataGrandLyonConfigEntry,
DataGrandLyonData,
DataGrandLyonTclCoordinator,
DataGrandLyonVelovCoordinator,
)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -22,10 +30,16 @@ async def async_setup_entry(
password=entry.data[CONF_PASSWORD],
)
coordinator = DataGrandLyonCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client)
velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client)
entry.runtime_data = coordinator
coordinators: list[DataUpdateCoordinator] = [tcl_coordinator, velov_coordinator]
await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators))
entry.runtime_data = DataGrandLyonData(
tcl_coordinator=tcl_coordinator,
velov_coordinator=velov_coordinator,
)
entry.async_on_unload(entry.add_update_listener(async_update_entry))
@@ -31,12 +31,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon binary sensor entities."""
coordinator = entry.runtime_data
velov_coordinator = entry.runtime_data.velov_coordinator
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
async_add_entities(
(
DataGrandLyonVelovBinarySensor(coordinator, subentry, description)
DataGrandLyonVelovBinarySensor(velov_coordinator, subentry, description)
for description in VELOV_BINARY_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
@@ -50,6 +50,5 @@ class DataGrandLyonVelovBinarySensor(DataGrandLyonVelovEntity, BinarySensorEntit
def is_on(self) -> bool:
"""Return true if the station is open."""
return (
self.coordinator.data.velov_stations[self._subentry_id].status
== VelovStationStatus.OPEN
self.coordinator.data[self._subentry_id].status == VelovStationStatus.OPEN
)
@@ -28,19 +28,20 @@ from .const import (
SUBENTRY_TYPE_VELOV_STATION,
)
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator]
@dataclass
class DataGrandLyonCoordinatorData:
"""Data returned by the coordinator."""
class DataGrandLyonData:
"""Runtime data for the Data Grand Lyon integration."""
stops: dict[str, list[TclPassage]]
velov_stations: dict[str, VelovStation]
tcl_coordinator: DataGrandLyonTclCoordinator
velov_coordinator: DataGrandLyonVelovCoordinator
class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonCoordinatorData]):
"""Coordinator for the Data Grand Lyon integration."""
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData]
class DataGrandLyonTclCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]):
"""Coordinator for TCL transit passages."""
config_entry: DataGrandLyonConfigEntry
@@ -56,82 +57,112 @@ class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonCoordinatorDat
hass,
LOGGER,
config_entry=entry,
name=DOMAIN,
name=f"{DOMAIN}_tcl",
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> DataGrandLyonCoordinatorData:
"""Fetch data for all monitored stops and Vélo'v stations."""
async def _async_update_data(self) -> dict[str, list[TclPassage]]:
"""Fetch data for all monitored stops."""
stop_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP)
)
if not stop_subentries:
return {}
try:
all_passages = await self.client.get_tcl_passages()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_tcl",
) from err
except (ClientError, TimeoutError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_tcl",
) from err
lines_stops = [
(subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
for subentry in stop_subentries
]
grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops)
stops: dict[str, list[TclPassage]] = {}
for subentry in stop_subentries:
key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
sorted_passages = sort_tcl_passages_by_time(grouped[key])
if sorted_passages:
stops[subentry.subentry_id] = sorted_passages
else:
LOGGER.warning(
"No TCL passages found for subentry %s",
subentry.subentry_id,
)
return stops
class DataGrandLyonVelovCoordinator(DataUpdateCoordinator[dict[str, VelovStation]]):
"""Coordinator for Vélo'v stations."""
config_entry: DataGrandLyonConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: DataGrandLyonConfigEntry,
client: DataGrandLyonClient,
) -> None:
"""Initialize the coordinator."""
self.client = client
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=f"{DOMAIN}_velov",
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> dict[str, VelovStation]:
"""Fetch data for all monitored Vélo'v stations."""
velov_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION)
)
if not velov_subentries:
return {}
has_stops = bool(stop_subentries)
has_velov = bool(velov_subentries)
stops: dict[str, list[TclPassage]] = {}
velov_stations: dict[str, VelovStation] = {}
tcl_success = not has_stops
velov_success = not has_velov
if has_stops:
try:
all_passages = await self.client.get_tcl_passages()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
LOGGER.warning("Error fetching TCL passages: %s", err)
except (ClientError, TimeoutError) as err:
LOGGER.warning("Error fetching TCL passages: %s", err)
else:
tcl_success = True
lines_stops = [
(subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
for subentry in stop_subentries
]
grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops)
for subentry in stop_subentries:
key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
stops[subentry.subentry_id] = sort_tcl_passages_by_time(
grouped[key]
)
if has_velov:
try:
all_stations = await self.client.get_velov_stations()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
LOGGER.warning("Error fetching Vélo'v stations: %s", err)
except (ClientError, TimeoutError) as err:
LOGGER.warning("Error fetching Vélo'v stations: %s", err)
else:
velov_success = True
station_ids = [
subentry.data[CONF_STATION_ID] for subentry in velov_subentries
]
found = find_velov_stations_by_ids(all_stations, station_ids)
for subentry in velov_subentries:
station = found[subentry.data[CONF_STATION_ID]]
if station is not None:
velov_stations[subentry.subentry_id] = station
else:
LOGGER.warning(
"Vélo'v station not found for subentry %s",
subentry.subentry_id,
)
if not tcl_success and not velov_success:
try:
all_stations = await self.client.get_velov_stations()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_all",
)
return DataGrandLyonCoordinatorData(stops=stops, velov_stations=velov_stations)
translation_key="update_failed_velov",
) from err
except (ClientError, TimeoutError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_velov",
) from err
station_ids = [subentry.data[CONF_STATION_ID] for subentry in velov_subentries]
found = find_velov_stations_by_ids(all_stations, station_ids)
velov_stations: dict[str, VelovStation] = {}
for subentry in velov_subentries:
station = found[subentry.data[CONF_STATION_ID]]
if station is not None:
velov_stations[subentry.subentry_id] = station
else:
LOGGER.warning(
"Vélo'v station not found for subentry %s",
subentry.subentry_id,
)
return velov_stations
@@ -16,18 +16,16 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
"coordinator_data": {
"stops": {
subentry_id: [asdict(passage) for passage in passages]
for subentry_id, passages in coordinator.data.stops.items()
for subentry_id, passages in entry.runtime_data.tcl_coordinator.data.items()
},
"velov_stations": {
subentry_id: asdict(station)
for subentry_id, station in coordinator.data.velov_stations.items()
for subentry_id, station in entry.runtime_data.velov_coordinator.data.items()
},
},
}
@@ -3,20 +3,25 @@
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import DOMAIN
from .coordinator import DataGrandLyonCoordinator
from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator
class DataGrandLyonEntity(CoordinatorEntity[DataGrandLyonCoordinator]):
class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator](
CoordinatorEntity[_CoordinatorT]
):
"""Base entity for Data Grand Lyon."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
coordinator: _CoordinatorT,
subentry: ConfigSubentry,
description: EntityDescription,
manufacturer: str,
@@ -37,23 +42,33 @@ class DataGrandLyonEntity(CoordinatorEntity[DataGrandLyonCoordinator]):
entry_type=DeviceEntryType.SERVICE,
)
@property
def available(self) -> bool:
"""Return True if subentry data is available."""
return super().available and self._subentry_id in self.coordinator.data
class DataGrandLyonVelovEntity(DataGrandLyonEntity):
class DataGrandLyonTclEntity(DataGrandLyonEntity[DataGrandLyonTclCoordinator]):
"""Base entity for Data Grand Lyon TCL stops."""
def __init__(
self,
coordinator: DataGrandLyonTclCoordinator,
subentry: ConfigSubentry,
description: EntityDescription,
) -> None:
"""Initialize the TCL entity."""
super().__init__(coordinator, subentry, description, "TCL", "Stop")
class DataGrandLyonVelovEntity(DataGrandLyonEntity[DataGrandLyonVelovCoordinator]):
"""Base entity for Data Grand Lyon Vélo'v stations."""
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
coordinator: DataGrandLyonVelovCoordinator,
subentry: ConfigSubentry,
description: EntityDescription,
) -> None:
"""Initialize the Vélo'v entity."""
super().__init__(coordinator, subentry, description, "JCDecaux", "Station")
@property
def available(self) -> bool:
"""Return True if the station data is available."""
return (
super().available
and self._subentry_id in self.coordinator.data.velov_stations
)
@@ -12,14 +12,13 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
from .entity import DataGrandLyonEntity, DataGrandLyonVelovEntity
from .coordinator import DataGrandLyonConfigEntry
from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity
PARALLEL_UPDATES = 0
@@ -170,12 +169,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon sensor entities."""
coordinator = entry.runtime_data
tcl_coordinator = entry.runtime_data.tcl_coordinator
velov_coordinator = entry.runtime_data.velov_coordinator
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
async_add_entities(
(
DataGrandLyonStopSensor(coordinator, subentry, description)
DataGrandLyonStopSensor(tcl_coordinator, subentry, description)
for description in STOP_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
@@ -184,41 +184,31 @@ async def async_setup_entry(
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
async_add_entities(
(
DataGrandLyonVelovSensor(coordinator, subentry, description)
DataGrandLyonVelovSensor(velov_coordinator, subentry, description)
for description in VELOV_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
)
class DataGrandLyonStopSensor(DataGrandLyonEntity, SensorEntity):
class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity):
"""Sensor for Data Grand Lyon stop departures."""
entity_description: DataGrandLyonStopSensorEntityDescription
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: DataGrandLyonStopSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, subentry, description, "TCL", "Stop")
def _get_departure(self) -> TclPassage | None:
"""Return the departure for this sensor's index, or None."""
departures = self.coordinator.data.stops.get(self._subentry_id, [])
index = self.entity_description.departure_index
if index >= len(departures):
return None
return departures[index]
@property
def available(self) -> bool:
"""Return True if the departure index exists."""
return super().available and self.entity_description.departure_index < len(
self.coordinator.data[self._subentry_id]
)
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
departure = self._get_departure()
if departure is None:
return None
departure = self.coordinator.data[self._subentry_id][
self.entity_description.departure_index
]
return self.entity_description.value_fn(departure)
@@ -227,18 +217,9 @@ class DataGrandLyonVelovSensor(DataGrandLyonVelovEntity, SensorEntity):
entity_description: DataGrandLyonVelovSensorEntityDescription
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: DataGrandLyonVelovSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, subentry, description)
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
return self.entity_description.value_fn(
self.coordinator.data.velov_stations[self._subentry_id]
self.coordinator.data[self._subentry_id]
)
@@ -158,11 +158,11 @@
"auth_failed": {
"message": "Authentication failed for Data Grand Lyon."
},
"update_failed_all": {
"message": "[%key:component::data_grand_lyon::exceptions::update_failed_all_stops::message%]"
"update_failed_tcl": {
"message": "Error fetching TCL departures from Data Grand Lyon."
},
"update_failed_all_stops": {
"message": "Error fetching Data Grand Lyon data: all requests failed."
"update_failed_velov": {
"message": "Error fetching Vélo'v stations from Data Grand Lyon."
}
}
}
+99 -6
View File
@@ -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:
+48 -19
View File
@@ -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(
+26 -17
View File
@@ -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}"
},
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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"]
}
+1 -2
View File
@@ -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")
+87 -29
View File
@@ -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"
+18 -13
View File
@@ -47,6 +47,23 @@ def create_elk_entities(
return entities
def generate_unique_id(prefix: str, element: Element) -> str:
"""Generate a unique id."""
# unique_id starts with elkm1_ iff there is no prefix
# it starts with elkm1m_{prefix} iff there is a prefix
# this is to avoid a conflict between
# prefix=foo, name=bar (which would be elkm1_foo_bar)
# - and -
# prefix="", name="foo bar" (which would be elkm1_foo_bar also)
# we could have used elkm1__foo_bar for the latter, but that
# would have been a breaking change
if prefix != "":
uid_start = f"elkm1m_{prefix}"
else:
uid_start = "elkm1"
return f"{uid_start}_{element.default_name('_')}".lower()
class ElkEntity(Entity):
"""Base class for all Elk entities."""
@@ -60,19 +77,7 @@ class ElkEntity(Entity):
self._mac = elk_data.mac
self._prefix = elk_data.prefix
self._temperature_unit: str = elk_data.config["temperature_unit"]
# unique_id starts with elkm1_ iff there is no prefix
# it starts with elkm1m_{prefix} iff there is a prefix
# this is to avoid a conflict between
# prefix=foo, name=bar (which would be elkm1_foo_bar)
# - and -
# prefix="", name="foo bar" (which would be elkm1_foo_bar also)
# we could have used elkm1__foo_bar for the latter, but that
# would have been a breaking change
if self._prefix != "":
uid_start = f"elkm1m_{self._prefix}"
else:
uid_start = "elkm1"
self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower()
self._unique_id = generate_unique_id(self._prefix, element)
self._attr_name = element.name
@property
+36 -4
View File
@@ -1,6 +1,6 @@
"""Support for control of ElkM1 sensors."""
from typing import Any
from typing import Any, cast
from elkm1_lib.const import SettingFormat, ZoneType
from elkm1_lib.counters import Counter
@@ -20,13 +20,19 @@ from homeassistant.components.sensor import (
from homeassistant.const import EntityCategory, UnitOfElectricPotential
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import ElkM1ConfigEntry
from .const import ATTR_VALUE, ELK_USER_CODE_SERVICE_SCHEMA
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
from .entity import (
ElkAttachedEntity,
ElkEntity,
create_elk_entities,
generate_unique_id,
)
from .util import deprecate_entity
SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh"
SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set"
@@ -58,11 +64,37 @@ async def async_setup_entry(
elk_data = config_entry.runtime_data
elk = elk_data.elk
entities: list[ElkEntity] = []
elk_settings: list[Setting] = []
create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities)
create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities)
create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities)
create_elk_entities(elk_data, elk.settings, "setting", ElkSetting, entities)
create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities)
entity_registry = er.async_get(hass)
for setting in elk.settings:
setting = cast(Setting, setting)
domain = (
"time" if setting.value_format == SettingFormat.TIME_OF_DAY else "number"
)
orig_unique_id = generate_unique_id(elk_data.prefix, setting)
new_unique_id = orig_unique_id
new_entity_id = f"{domain}.elkm1_{setting.name.replace(' ', '_')}".lower()
if deprecate_entity(
hass,
entity_registry,
"sensor",
orig_unique_id,
f"deprecated_sensor_{orig_unique_id}",
"deprecated_sensor",
new_unique_id,
new_entity_id,
):
elk_settings.append(setting)
create_elk_entities(elk_data, elk_settings, "setting", ElkSetting, entities)
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
@@ -58,6 +58,16 @@
}
}
},
"issues": {
"deprecated_sensor": {
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nUpdate your dashboards, templates, automations and scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
"title": "Deprecated sensor detected"
},
"deprecated_sensor_scripts": {
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nUpdate the above automations or scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
"title": "[%key:component::elkm1::issues::deprecated_sensor::title%]"
}
},
"services": {
"alarm_arm_home_instant": {
"description": "Arms the Elk-M1 in home instant mode.",
+102
View File
@@ -0,0 +1,102 @@
"""Utility helpers for the elkm1 integration."""
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .const import DOMAIN
def deprecate_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
platform_domain: str,
entity_unique_id: str,
issue_id: str,
issue_string: str,
replacement_entity_unique_id: str,
replacement_entity_id: str,
version: str = "2026.11.0",
) -> bool:
"""Create an issue for deprecated entities."""
if entity_id := entity_registry.async_get_entity_id(
platform_domain, DOMAIN, entity_unique_id
):
entity_entry = entity_registry.async_get(entity_id)
if not entity_entry:
async_delete_issue(hass, DOMAIN, issue_id)
return False
items = get_automations_and_scripts_using_entity(hass, entity_id)
if entity_entry.disabled and not items:
entity_registry.async_remove(entity_id)
async_delete_issue(hass, DOMAIN, issue_id)
return False
translation_key = issue_string
placeholders = {
"entity_id": entity_id,
"entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
"replacement_entity_id": (
entity_registry.async_get_entity_id(
Platform.NUMBER, DOMAIN, replacement_entity_unique_id
)
or entity_registry.async_get_entity_id(
Platform.TIME, DOMAIN, replacement_entity_unique_id
)
or replacement_entity_id
),
}
if items:
translation_key = f"{translation_key}_scripts"
placeholders["items"] = "\n".join(items)
async_create_issue(
hass,
DOMAIN,
issue_id,
breaks_in_ha_version=version,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=placeholders,
)
return True
async_delete_issue(hass, DOMAIN, issue_id)
return False
def get_automations_and_scripts_using_entity(
hass: HomeAssistant,
entity_id: str,
) -> list[str]:
"""Get automations and scripts using an entity."""
automations = automations_with_entity(hass, entity_id)
scripts = scripts_with_entity(hass, entity_id)
if not automations and not scripts:
return []
entity_registry = er.async_get(hass)
items: list[str] = []
for integration, entities in (
("automation", automations),
("script", scripts),
):
for used_entity_id in entities:
if item := entity_registry.async_get(used_entity_id):
items.append(
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
)
else:
items.append(f"- `{used_entity_id}`")
return items
+12
View File
@@ -138,6 +138,9 @@ class GridSourceType(TypedDict):
cost_adjustment_day: float
# An optional custom name for display in energy graphs
name: NotRequired[str]
class SolarSourceType(TypedDict):
"""Dictionary holding the source of energy production."""
@@ -148,6 +151,9 @@ class SolarSourceType(TypedDict):
stat_rate: NotRequired[str]
config_entry_solar_forecast: list[str] | None
# An optional custom name for display in energy graphs
name: NotRequired[str]
class BatterySourceType(TypedDict):
"""Dictionary holding the source of battery storage."""
@@ -166,6 +172,9 @@ class BatterySourceType(TypedDict):
# statistic_id of a sensor (unit %) reporting the battery state of charge
stat_soc: NotRequired[str]
# An optional custom name for display in energy graphs
name: NotRequired[str]
class GasSourceType(TypedDict):
"""Dictionary holding the source of gas consumption."""
@@ -464,6 +473,7 @@ GRID_SOURCE_SCHEMA = vol.All(
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
vol.Required("cost_adjustment_day"): vol.Coerce(float),
vol.Optional("name"): str,
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
@@ -483,6 +493,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
vol.Optional("name"): str,
}
)
BATTERY_SOURCE_SCHEMA = vol.Schema(
@@ -495,6 +506,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
vol.Optional("stat_soc"): str,
vol.Optional("name"): str,
}
)
@@ -16,6 +16,9 @@ PLATFORMS = [
INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired)
SETUP_RETRY_TIMEOUT = 50
OPERATIONAL_RETRY_TIMEOUT = 200
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES = "diagnostics_include_fixtures"
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES_DEFAULT_VALUE = False
@@ -18,7 +18,12 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN, INVALID_AUTH_ERRORS
from .const import (
DOMAIN,
INVALID_AUTH_ERRORS,
OPERATIONAL_RETRY_TIMEOUT,
SETUP_RETRY_TIMEOUT,
)
SCAN_INTERVAL = timedelta(seconds=60)
@@ -50,6 +55,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.username = entry_data[CONF_USERNAME]
self.password = entry_data[CONF_PASSWORD]
self._setup_complete = False
self._operational_timeout = False
self.envoy_firmware = ""
self.interface = None
self._cancel_token_refresh: CALLBACK_TYPE | None = None
@@ -265,10 +271,15 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
if not self._setup_complete:
_LOGGER.debug("update on try %s, setup not complete", tries)
self.envoy.set_retry_policy(max_delay=SETUP_RETRY_TIMEOUT)
self._operational_timeout = False
await self._async_setup_and_authenticate()
self._async_mark_setup_complete()
# dump all received data in debug mode to assist troubleshooting
envoy_data = await envoy.update()
if not self._operational_timeout:
self.envoy.set_retry_policy(max_delay=OPERATIONAL_RETRY_TIMEOUT)
self._operational_timeout = True
except INVALID_AUTH_ERRORS as err:
_LOGGER.debug("update on try %s, INVALID_AUTH_ERRORS %s", tries, err)
if self._setup_complete and tries == 0:
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["pyenvisalink"],
"quality_scale": "legacy",
"requirements": ["pyenvisalink==4.7"]
"requirements": ["pyenvisalink==4.9"]
}
+8 -12
View File
@@ -122,19 +122,15 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout)
# We need to wake hibernating cameras.
# First create EZVIZ API instance.
await self.hass.async_add_executor_job(ezviz_client.login)
def _login_wake_and_test() -> None:
# Login to create EZVIZ API instance.
ezviz_client.login()
# Wake hibernating camera.
ezviz_client.get_detection_sensibility(data[ATTR_SERIAL])
# Attempt an authenticated RTSP DESCRIBE request.
_test_camera_rtsp_creds(data)
# Secondly try to wake hybernating camera.
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self.hass.async_add_executor_job(
ezviz_client.get_detection_sensibility, data[ATTR_SERIAL]
)
# Thirdly attempts an authenticated RTSP DESCRIBE request.
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data)
await self.hass.async_add_executor_job(_login_wake_and_test)
return self.async_create_entry(
title=data[ATTR_SERIAL],
+4 -4
View File
@@ -54,6 +54,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
),
)
hass.data.setdefault(FRITZ_DATA_KEY, FritzData())
try:
await avm_wrapper.async_setup(entry.options)
except FRITZ_AUTH_EXCEPTIONS as ex:
@@ -68,13 +70,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
raise ConfigEntryAuthFailed("Missing UPnP configuration")
await avm_wrapper.async_config_entry_first_refresh()
await avm_wrapper.async_trigger_cleanup()
entry.runtime_data = avm_wrapper
if FRITZ_DATA_KEY not in hass.data:
hass.data[FRITZ_DATA_KEY] = FritzData()
# Load the other platforms like switch
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -89,6 +87,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo
if avm_wrapper.unique_id in fritz_data.tracked:
fritz_data.tracked.pop(avm_wrapper.unique_id)
fritz_data.profile_switches.pop(avm_wrapper.unique_id)
fritz_data.wol_buttons.pop(avm_wrapper.unique_id)
if not bool(fritz_data.tracked):
hass.data.pop(FRITZ_DATA_KEY)
-3
View File
@@ -217,9 +217,6 @@ def _async_wol_buttons_list(
new_wols: list[FritzBoxWOLButton] = []
if avm_wrapper.unique_id not in data_fritz.wol_buttons:
data_fritz.wol_buttons[avm_wrapper.unique_id] = set()
for mac, device in avm_wrapper.devices.items():
if _is_tracked(mac, data_fritz.wol_buttons.values()):
_LOGGER.debug("Skipping wol button creation for device %s", device.hostname)
@@ -187,6 +187,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self._options = options
await self.hass.async_add_executor_job(self.setup)
self.hass.data[FRITZ_DATA_KEY].tracked[self.unique_id] = set()
self.hass.data[FRITZ_DATA_KEY].profile_switches[self.unique_id] = set()
self.hass.data[FRITZ_DATA_KEY].wol_buttons[self.unique_id] = set()
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
@@ -715,6 +719,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
) and entry_mac not in device_hosts:
_LOGGER.debug("Removing orphan entity entry %s", entity.entity_id)
entity_reg.async_remove(entity.entity_id)
self._devices.pop(entry_mac, None)
device_reg = dr.async_get(self.hass)
valid_connections = {
@@ -729,6 +734,29 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
device.id, remove_config_entry_id=config_entry.entry_id
)
fritz_data = self.hass.data[FRITZ_DATA_KEY]
tracked = fritz_data.tracked.get(self.unique_id, set())
for mac in tracked.copy():
if mac in device_hosts:
continue
_LOGGER.debug("Removing orphan mac address %s from device trackers", mac)
tracked.remove(mac)
profile_switches = fritz_data.profile_switches.get(self.unique_id, set())
for mac in profile_switches.copy():
if mac in device_hosts:
continue
_LOGGER.debug("Removing orphan mac address %s from profile switches", mac)
profile_switches.remove(mac)
wol_buttons = fritz_data.wol_buttons.get(self.unique_id, set())
for mac in wol_buttons.copy():
if mac in device_hosts:
continue
_LOGGER.debug("Removing orphan mac address %s from WOL buttons", mac)
wol_buttons.remove(mac)
class AvmWrapper(FritzBoxTools):
"""Setup AVM wrapper for API calls."""
@@ -51,9 +51,6 @@ def _async_add_entities(
"""Add new tracker entities from the AVM device."""
new_tracked = []
if avm_wrapper.unique_id not in data_fritz.tracked:
data_fritz.tracked[avm_wrapper.unique_id] = set()
for mac, device in avm_wrapper.devices.items():
if device_filter_out_from_trackers(mac, device, data_fritz.tracked.values()):
continue
-3
View File
@@ -242,9 +242,6 @@ async def _async_profile_entities_list(
if "X_AVM-DE_HostFilter1" not in avm_wrapper.connection.services:
return new_profiles
if avm_wrapper.unique_id not in data_fritz.profile_switches:
data_fritz.profile_switches[avm_wrapper.unique_id] = set()
for mac, device in avm_wrapper.devices.items():
if device_filter_out_from_trackers(
mac, device, data_fritz.profile_switches.values()
+1 -2
View File
@@ -6,6 +6,5 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
# in script/hassfest/docker.py.
# Kept in sync with the go2rtc image pinned in the root Dockerfile by Renovate.
RECOMMENDED_VERSION = "1.9.14"
@@ -57,7 +57,9 @@ def sensor_update_to_bluetooth_data_update(
device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[
description.device_class
]
for device_key, description in sensor_update.binary_entity_descriptions.items()
for device_key, description in (
sensor_update.binary_entity_descriptions.items()
)
if description.device_class
},
entity_data={
@@ -36,7 +36,9 @@ async def async_get_config_entry_diagnostics(
"data": {
"valve_controller": {
api_category: async_redact_data(coordinator.data, TO_REDACT)
for api_category, coordinator in data.valve_controller_coordinators.items()
for api_category, coordinator in (
data.valve_controller_coordinators.items()
)
},
"paired_sensors": [
async_redact_data(coordinator.data, TO_REDACT)
+9 -4
View File
@@ -21,6 +21,13 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
_FAN_SPEED_PERCENTAGE_KEY = (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
)
_FAN_SPEED_MODE_KEY = (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
)
FAN_SPEED_MODE_OPTIONS = {
"auto": (
"HeatingVentilationAirConditioning"
@@ -116,8 +123,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
def update_native_value(self) -> None:
"""Set the speed percentage and speed mode values."""
option_value = None
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
if event := self.appliance.events.get(EventKey(option_key)):
if event := self.appliance.events.get(EventKey(_FAN_SPEED_PERCENTAGE_KEY)):
option_value = event.value
self._attr_percentage = (
cast(int, option_value) if option_value is not None else None
@@ -142,8 +148,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
def update_preset_mode(self) -> None:
"""Set the preset mode value."""
option_value = None
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
if event := self.appliance.events.get(EventKey(option_key)):
if event := self.appliance.events.get(EventKey(_FAN_SPEED_MODE_KEY)):
option_value = event.value
self._attr_preset_mode = (
FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value))
@@ -133,7 +133,9 @@ SELECT_ENTITY_DESCRIPTIONS = (
translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items()
for translation_key, value in (
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items()
)
},
),
HomeConnectSelectEntityDescription(
@@ -6,6 +6,7 @@ from typing import Any
import httpx
from iaqualink.exception import (
AqualinkServiceException,
AqualinkServiceThrottledException,
AqualinkServiceUnauthorizedException,
)
@@ -44,6 +45,12 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
await self.system.update()
except AqualinkServiceUnauthorizedException as err:
raise ConfigEntryAuthFailed("Invalid credentials for iAquaLink") from err
except AqualinkServiceThrottledException:
_LOGGER.warning(
"Rate limited by iAquaLink system %s, will retry later",
self.system.serial,
)
return
except (AqualinkServiceException, httpx.HTTPError) as err:
raise UpdateFailed(
f"Unable to update iAquaLink system {self.system.serial}: {err}"
@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "platinum",
"requirements": ["aioimmich==0.14.0"]
"requirements": ["aioimmich==0.14.1"]
}
+3 -1
View File
@@ -280,7 +280,9 @@ class KNXModule:
or next(
(
_transcoder
for _filter, _transcoder in self._address_filter_transcoder.items()
for _filter, _transcoder in (
self._address_filter_transcoder.items()
)
if _filter.match(telegram.destination_address)
),
None,
+13 -7
View File
@@ -114,21 +114,27 @@ async def service_event_register_modify(call: ServiceCall) -> None:
knx_module = get_knx_module(call.hass)
attr_address = call.data[KNX_ADDRESS]
group_addresses = list(map(parse_device_group_address, attr_address))
group_addresses = set(map(parse_device_group_address, attr_address))
if call.data.get(SERVICE_KNX_ATTR_REMOVE):
_error_gas = set()
for group_address in group_addresses:
try:
knx_module.knx_event_callback.group_addresses.remove(group_address)
# pylint: disable-next=home-assistant-action-swallowed-exception
except ValueError:
_LOGGER.warning(
"Service event_register could not remove event for '%s'",
str(group_address),
)
_error_gas.add(group_address)
if group_address in knx_module.group_address_transcoder:
del knx_module.group_address_transcoder[group_address]
return
if not _error_gas:
return
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_event_register_ga_not_found",
translation_placeholders={
"group_addresses": ", ".join(map(str, sorted(_error_gas)))
},
)
if (dpt := call.data.get(CONF_TYPE)) and (
transcoder := DPTBase.parse_transcoder(dpt)
@@ -1094,6 +1094,9 @@
"integration_not_loaded": {
"message": "KNX integration is not loaded."
},
"service_event_register_ga_not_found": {
"message": "Could not find registered event for `{group_addresses}` to remove."
},
"service_exposure_remove_not_found": {
"message": "Could not find exposure for `{group_address}` to remove."
},
@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.2.4"]
"requirements": ["pylamarzocco==2.2.5"]
}
@@ -131,7 +131,9 @@ async def async_setup_entry(
entities.extend(
LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry)
for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules
for wake_up_sleep_entry in (
coordinator.device.schedule.smart_wake_up_sleep.schedules
)
)
entities.append(
+1 -1
View File
@@ -22,5 +22,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aiolyric"],
"requirements": ["aiolyric==2.0.2"]
"requirements": ["aiolyric==2.1.0"]
}
@@ -58,6 +58,9 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity):
self._attr_is_on = value
_PUMP_STATUS = clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap
_VALVE_FAULT = clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
# device specific: translate Hue motion to sensor to HA Motion sensor
@@ -374,11 +377,7 @@ DISCOVERY_SCHEMAS = [
entity_category=EntityCategory.DIAGNOSTIC,
# DeviceFault or SupplyFault bit enabled
device_to_ha=lambda x: bool(
x
& (
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
| clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
)
x & (_PUMP_STATUS.kDeviceFault | _PUMP_STATUS.kSupplyFault)
),
),
entity_class=MatterBinarySensor,
@@ -393,10 +392,7 @@ DISCOVERY_SCHEMAS = [
key="PumpStatusRunning",
translation_key="pump_running",
device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha=lambda x: bool(
x
& clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
),
device_to_ha=lambda x: bool(x & _PUMP_STATUS.kRunning),
),
entity_class=MatterBinarySensor,
required_attributes=(
@@ -442,10 +438,7 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# GeneralFault bit from ValveFault attribute
device_to_ha=lambda x: bool(
x
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
),
device_to_ha=lambda x: bool(x & _VALVE_FAULT.kGeneralFault),
),
entity_class=MatterBinarySensor,
required_attributes=(
@@ -461,10 +454,7 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# Blocked bit from ValveFault attribute
device_to_ha=lambda x: bool(
x
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
),
device_to_ha=lambda x: bool(x & _VALVE_FAULT.kBlocked),
),
entity_class=MatterBinarySensor,
required_attributes=(
@@ -480,10 +470,7 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# Leaking bit from ValveFault attribute
device_to_ha=lambda x: bool(
x
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
),
device_to_ha=lambda x: bool(x & _VALVE_FAULT.kLeaking),
),
entity_class=MatterBinarySensor,
required_attributes=(
@@ -21,6 +21,7 @@ from .models import UNSET, MatterDiscoverySchema, MatterEntityInfo
from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS
from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS
from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS
from .siren import DISCOVERY_SCHEMAS as SIREN_SCHEMAS
from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS
from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS
from .vacuum import DISCOVERY_SCHEMAS as VACUUM_SCHEMAS
@@ -39,6 +40,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = {
Platform.NUMBER: NUMBER_SCHEMAS,
Platform.SELECT: SELECT_SCHEMAS,
Platform.SENSOR: SENSOR_SCHEMAS,
Platform.SIREN: SIREN_SCHEMAS,
Platform.SWITCH: SWITCH_SCHEMAS,
Platform.UPDATE: UPDATE_SCHEMAS,
Platform.VACUUM: VACUUM_SCHEMAS,
+9 -15
View File
@@ -38,10 +38,12 @@ from .util import (
renormalize,
)
_CC_COLOR_MODE = clusters.ColorControl.Enums.ColorModeEnum
COLOR_MODE_MAP = {
clusters.ColorControl.Enums.ColorModeEnum.kCurrentHueAndCurrentSaturation: ColorMode.HS,
clusters.ColorControl.Enums.ColorModeEnum.kCurrentXAndCurrentY: ColorMode.XY,
clusters.ColorControl.Enums.ColorModeEnum.kColorTemperatureMireds: ColorMode.COLOR_TEMP,
_CC_COLOR_MODE.kCurrentHueAndCurrentSaturation: ColorMode.HS,
_CC_COLOR_MODE.kCurrentXAndCurrentY: ColorMode.XY,
_CC_COLOR_MODE.kColorTemperatureMireds: ColorMode.COLOR_TEMP,
}
# Maximum Mireds value per the Matter spec is 65279
@@ -362,24 +364,16 @@ class MatterLight(MatterEntity, LightEntity):
assert capabilities is not None
if (
capabilities
& clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kHueSaturation
):
color_caps = clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap
if capabilities & color_caps.kHueSaturation:
supported_color_modes.add(ColorMode.HS)
self._supports_color = True
if (
capabilities
& clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kXy
):
if capabilities & color_caps.kXy:
supported_color_modes.add(ColorMode.XY)
self._supports_color = True
if (
capabilities
& clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kColorTemperature
):
if capabilities & color_caps.kColorTemperature:
supported_color_modes.add(ColorMode.COLOR_TEMP)
self._supports_color_temperature = True
min_mireds = self.get_matter_attribute_value(
+3 -1
View File
@@ -162,7 +162,9 @@ ESA_STATE_MAP = {
clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOffline: "offline",
clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOnline: "online",
clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kFault: "fault",
clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kPowerAdjustActive: "power_adjust_active",
clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kPowerAdjustActive: (
"power_adjust_active"
),
clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kPaused: "paused",
}
+69
View File
@@ -0,0 +1,69 @@
"""Matter sirens."""
from dataclasses import dataclass
from typing import Any
from matter_server.common.custom_clusters import HeimanCluster
from homeassistant.components.siren import (
SirenEntity,
SirenEntityDescription,
SirenEntityFeature,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
from .helpers import MatterConfigEntry
from .models import MatterDiscoverySchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MatterConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter sirens from Config Entry."""
matter = config_entry.runtime_data.adapter
matter.register_platform_handler(Platform.SIREN, async_add_entities)
@dataclass(frozen=True, kw_only=True)
class MatterSirenEntityDescription(SirenEntityDescription, MatterEntityDescription):
"""Describe Matter Siren entities."""
class MatterSiren(MatterEntity, SirenEntity):
"""Representation of a Matter siren."""
entity_description: MatterSirenEntityDescription
_attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
await self.write_attribute(value=1)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the siren off."""
await self.write_attribute(value=0)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
self._attr_is_on = bool(value) if value is not None else None
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.SIREN,
entity_description=MatterSirenEntityDescription(
key="HeimanSiren",
translation_key="siren",
),
entity_class=MatterSiren,
required_attributes=(HeimanCluster.Attributes.SirenActive,),
),
]
@@ -610,6 +610,11 @@
"name": "Target opening position"
}
},
"siren": {
"siren": {
"name": "[%key:component::siren::title%]"
}
},
"switch": {
"child_lock": {
"name": "Child lock"
@@ -88,7 +88,10 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
temporary_setpoint: int | None = None,
) -> None:
"""Set boost."""
boost_info: clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
boost_info_cls = (
clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct
)
boost_info = boost_info_cls(
duration=duration,
emergencyBoost=emergency_boost,
temporarySetpoint=(
@@ -23,6 +23,7 @@ from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType
# Paths that we don't need to sign
PATHS_WITHOUT_AUTH = (
"/local/",
"/api/tts_proxy/",
"/api/esphome/ffmpeg_proxy/",
"/api/assist_satellite/static/",
+4 -3
View File
@@ -197,12 +197,13 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
conn = self.coordinator.mill_data_connection
if hvac_mode == HVACMode.HEAT:
await self.coordinator.mill_data_connection.set_operation_mode_control_individually()
await conn.set_operation_mode_control_individually()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.set_operation_mode_off()
await conn.set_operation_mode_off()
elif hvac_mode == HVACMode.AUTO:
await self.coordinator.mill_data_connection.set_operation_mode_weekly_program()
await conn.set_operation_mode_weekly_program()
await self.coordinator.async_request_refresh()
@callback
@@ -15,5 +15,5 @@
"iot_class": "local_push",
"loggers": ["opendisplay"],
"quality_scale": "silver",
"requirements": ["py-opendisplay==5.9.0"]
"requirements": ["py-opendisplay==7.0.0"]
}
@@ -218,7 +218,7 @@ async def _async_upload_image(call: ServiceCall) -> None:
pil_image,
refresh_mode=refresh_mode,
dither_mode=dither_mode,
tone_compression=tone_compression,
tone=tone_compression,
fit=fit_mode,
rotate=rotation,
)
+60 -6
View File
@@ -164,6 +164,22 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override since BioclimaticPergola uses core:SlatsOpenClosedState
# and core:SlateOrientationState (tilt-only, no position)
# uiClass is Pergola
OverkizCoverDescription(
key=UIWidget.BIOCLIMATIC_PERGOLA,
device_class=CoverDeviceClass.AWNING,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED,
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
open_tilt_command=OverkizCommand.OPEN_SLATS,
close_tilt_command=OverkizCommand.CLOSE_SLATS,
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override since PositionableGarageDoor reports
# core:OpenClosedUnknownState instead of core:OpenClosedState
# uiClass is GarageDoor
@@ -177,6 +193,17 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
),
# Needs override since DiscretePositionableGarageDoor reports
# core:OpenClosedUnknownState instead of core:OpenClosedState
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.DISCRETE_POSITIONABLE_GARAGE_DOOR,
device_class=CoverDeviceClass.GARAGE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
),
# Needs override since PositionableGarageDoorWithPartialPosition reports
# core:OpenClosedPartialState instead of core:OpenClosedState
# uiClass is GarageDoor
@@ -210,6 +237,31 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
open_command=OverkizCommand.CYCLE,
close_command=OverkizCommand.CYCLE,
),
# Needs override since UpDownGarageDoor4T only supports the cycle command
# (rts:GarageDoor4TRTSComponent)
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.UP_DOWN_GARAGE_DOOR_4T,
device_class=CoverDeviceClass.GARAGE,
open_command=OverkizCommand.CYCLE,
close_command=OverkizCommand.CYCLE,
),
# Needs override since OpenCloseSlidingGarageDoor4T only supports the cycle command
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.OPEN_CLOSE_SLIDING_GARAGE_DOOR_4T,
device_class=CoverDeviceClass.GARAGE,
open_command=OverkizCommand.CYCLE,
close_command=OverkizCommand.CYCLE,
),
# Needs override since OpenCloseSlidingGate4T only supports the cycle command
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.OPEN_CLOSE_SLIDING_GATE_4T,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.CYCLE,
close_command=OverkizCommand.CYCLE,
),
# Needs override since CyclicGarageDoor only supports the cycle command
# (io:CyclicGarageOpenerIOComponent)
# uiClass is GarageDoor
@@ -371,15 +423,12 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
OverkizCoverDescription(
key=UIClass.PERGOLA,
device_class=CoverDeviceClass.AWNING,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED,
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
open_tilt_command=OverkizCommand.OPEN_SLATS,
close_tilt_command=OverkizCommand.CLOSE_SLATS,
stop_tilt_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
),
OverkizCoverDescription(
key=UIClass.ROLLER_SHUTTER,
@@ -427,9 +476,14 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
OverkizCoverDescription(
key=UIClass.VENETIAN_BLIND,
device_class=CoverDeviceClass.BLIND,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
open_tilt_command=OverkizCommand.TILT_UP,
close_tilt_command=OverkizCommand.TILT_DOWN,
stop_tilt_command=OverkizCommand.STOP,
@@ -7,6 +7,7 @@ from pyprusalink.types import JobInfo, PrinterInfo, PrinterStatus
from pyprusalink.types_legacy import LegacyPrinterStatus
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
@@ -30,6 +31,17 @@ class PrusaLinkBinarySensorEntityDescription[
BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] = {
"status": (
PrusaLinkBinarySensorEntityDescription[PrinterStatus](
key="printer.status_connect",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
value_fn=lambda data: data["printer"]["status_connect"]["ok"],
supported_fn=lambda data: (
data["printer"].get("status_connect") is not None
and data["printer"]["status_connect"].get("ok") is not None
),
),
),
"info": (
PrusaLinkBinarySensorEntityDescription[PrinterInfo](
key="info.mmu",
@@ -37,6 +49,20 @@ BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] =
value_fn=lambda data: data["mmu"],
entity_registry_enabled_default=False,
),
PrusaLinkBinarySensorEntityDescription[PrinterInfo](
key="info.sd_ready",
translation_key="sd_ready",
value_fn=lambda data: data["sd_ready"],
supported_fn=lambda data: data.get("sd_ready") is not None,
entity_registry_enabled_default=False,
),
PrusaLinkBinarySensorEntityDescription[PrinterInfo](
key="info.farm_mode",
translation_key="farm_mode",
value_fn=lambda data: data["farm_mode"],
supported_fn=lambda data: data.get("farm_mode") is not None,
entity_registry_enabled_default=False,
),
),
}
@@ -55,6 +81,7 @@ async def async_setup_entry(
entities.extend(
PrusaLinkBinarySensorEntity(coordinator, sensor_description)
for sensor_description in binary_sensors
if sensor_description.supported_fn(coordinator.data)
)
async_add_entities(entities)
@@ -1,5 +1,16 @@
{
"entity": {
"binary_sensor": {
"farm_mode": {
"default": "mdi:server-network"
},
"mmu": {
"default": "mdi:printer-3d-nozzle-alert"
},
"sd_ready": {
"default": "mdi:micro-sd"
}
},
"button": {
"cancel_job": {
"default": "mdi:cancel"
@@ -18,8 +18,14 @@
},
"entity": {
"binary_sensor": {
"farm_mode": {
"name": "Farm mode"
},
"mmu": {
"name": "MMU"
},
"sd_ready": {
"name": "SD card"
}
},
"button": {
+1
View File
@@ -11,6 +11,7 @@ PLATFORMS: list[Platform] = [
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
+91
View File
@@ -0,0 +1,91 @@
"""Support for Qbus select."""
from qbusmqttapi.const import KEY_PROPERTIES_VALUE
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.state import QbusMqttStepperState, StateType
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, create_new_entities
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
"""Add newly discovered outputs as entities."""
entities = create_new_entities(
coordinator,
added_outputs,
lambda output: output.type == "stepper",
QbusStepper,
)
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
class QbusStepper(QbusEntity, SelectEntity):
"""Representation of a Qbus stepper entity."""
_state_cls = QbusMqttStepperState
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize stepper entity."""
super().__init__(mqtt_output, link_to_main_device=True)
self._attr_name = mqtt_output.name.title()
value_settings: dict = mqtt_output.properties.get(KEY_PROPERTIES_VALUE, {})
value_list: list[dict] = value_settings.get("valueList", [])
self._name_to_value: dict[str, int] = {
item["name"]: item["value"] for item in value_list
}
self._value_to_name: dict[int, str] = {
item["value"]: item["name"] for item in value_list
}
self._attr_options = [item["name"] for item in value_list]
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
value = self._name_to_value.get(option)
if value is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_option",
translation_placeholders={
"option": option,
"options": ", ".join(self._attr_options),
},
)
state = QbusMqttStepperState(id=self._mqtt_output.id, type=StateType.STATE)
state.write_value(value)
await self._async_publish_output_state(state)
async def _handle_state_received(self, state: QbusMqttStepperState) -> None:
"""Update the state from a received Qbus state."""
value = state.read_value()
if value is not None:
self._attr_current_option = self._value_to_name.get(value)
@@ -41,6 +41,9 @@
}
},
"exceptions": {
"invalid_option": {
"message": "Option \"{option}\" is not valid. Valid options are: {options}."
},
"invalid_preset": {
"message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}."
}

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