mirror of
https://github.com/home-assistant/core.git
synced 2026-06-04 02:13:42 +02:00
Compare commits
200 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 419936f4c2 | |||
| a21212ab7e | |||
| 71eefdc716 | |||
| f5819d400e | |||
| 31fcbe7bce | |||
| 3664eb4942 | |||
| 2f03b7c427 | |||
| 2d8cebf99d | |||
| 8ca4471418 | |||
| 02720605ae | |||
| fb28825f1f | |||
| 25ce81732b | |||
| 9c1cc55482 | |||
| 477756da5b | |||
| ec995a3472 | |||
| a19f3045e7 | |||
| 6a836bd1d9 | |||
| 01d390293b | |||
| b069bc2f03 | |||
| 7e36d265ed | |||
| 155cb38090 | |||
| 0f01148207 | |||
| a65503f203 | |||
| 063fa8df7e | |||
| 1865c16041 | |||
| cad177cdff | |||
| be2aaf926b | |||
| d7219aa025 | |||
| 7fb475aad1 | |||
| 25f18c6082 | |||
| c901160fb3 | |||
| 018e42e670 | |||
| 04442bb73e | |||
| eb3fd52619 | |||
| 8e19fd280e | |||
| e45f64b880 | |||
| 480a8d536f | |||
| 0abc9b787b | |||
| ce46be110d | |||
| c22f10bf87 | |||
| 97d9c23855 | |||
| 477d8bde6b | |||
| da12c94f27 | |||
| 52afa0627e | |||
| 9b8b8c2d82 | |||
| db0fc36a54 | |||
| cb00ee1503 | |||
| ae23c5db4d | |||
| d0b4274c2b | |||
| ecd132b60c | |||
| 58d5db7494 | |||
| 3c0a34cc66 | |||
| de70e86eae | |||
| 9d5bd5daff | |||
| c314ee77e1 | |||
| 2d262d940b | |||
| 425ce17d9c | |||
| 38a266ea6c | |||
| 55af1c3b3c | |||
| 7f1dce45c5 | |||
| 62bfaa9d92 | |||
| 088fd398e2 | |||
| 519104166d | |||
| 25f64eb78c | |||
| 3c0f6b7f2a | |||
| 94a976d974 | |||
| e377e9889a | |||
| cc897b926d | |||
| 6c04ca3685 | |||
| e03bc6faa5 | |||
| fed946760d | |||
| 7a32cdc250 | |||
| 6bb027f008 | |||
| 460c67e9c5 | |||
| 33a51acd7b | |||
| 3df68f2088 | |||
| 35e647de20 | |||
| f5aed4b61e | |||
| c5a3a50d7b | |||
| d256226e46 | |||
| b537978260 | |||
| e3cd4cdd37 | |||
| b5997c2e9e | |||
| defe00dd92 | |||
| 2fdad3dc41 | |||
| c86cb9281d | |||
| ebd8d0b9c9 | |||
| 2a38d165d6 | |||
| 8c569b4aa3 | |||
| bea942fbaa | |||
| 421d3b0835 | |||
| adfb33489a | |||
| 6b2c7423e4 | |||
| 67059e64e0 | |||
| 6ab4d8933d | |||
| 3d8369db81 | |||
| f459946e86 | |||
| 0c7c3e9fc7 | |||
| 93615edc60 | |||
| fdb581ea7f | |||
| 30f03dc01e | |||
| 4f92c1686b | |||
| a676072e0d | |||
| 0ebcbf33ba | |||
| cb544f2f67 | |||
| 26c5c37f53 | |||
| b9ed8e91df | |||
| 33a721245c | |||
| 840243db9c | |||
| 740778f00b | |||
| 1ec5e25b6b | |||
| 83c35b8b4d | |||
| 02b760f142 | |||
| 0c10c2c16b | |||
| 144257a377 | |||
| c5341b2ff6 | |||
| 6aebf78961 | |||
| 759039728b | |||
| 1d2f0793d7 | |||
| 14fcb6c2d6 | |||
| 5763829b4b | |||
| 7dfec6ef3d | |||
| efe55f247a | |||
| 85f3141776 | |||
| a175c7c4be | |||
| 03c83091ab | |||
| accebd7f38 | |||
| 9d3bb346e9 | |||
| d13721980e | |||
| ac6b5a5850 | |||
| 16dfa99673 | |||
| f51a02bbda | |||
| 6a51b21242 | |||
| 5eb502851c | |||
| ef20418c76 | |||
| 94ca34fd0c | |||
| 8634c22a53 | |||
| 5681ba40f1 | |||
| 8a9a1c5fed | |||
| c587e101af | |||
| 6eeeac46f3 | |||
| 86542b8ad0 | |||
| 7e07e7062c | |||
| d7c13fee27 | |||
| a0a44f7a25 | |||
| 2bba907013 | |||
| 0dcb8fc507 | |||
| 18e6f67650 | |||
| e5fad17e17 | |||
| 219b9cbcaa | |||
| 309b26f809 | |||
| e78cb0114d | |||
| 06a4247078 | |||
| 181e21dd2c | |||
| 31354d4129 | |||
| 57308d7760 | |||
| c07fed05df | |||
| 13ef737873 | |||
| 0a1510135c | |||
| 6f6b7888cd | |||
| b9173e36fb | |||
| a65ca9c86b | |||
| fc12d6fbb6 | |||
| 2a6b686254 | |||
| 4d841e4d84 | |||
| df08e9f311 | |||
| d53e40eea8 | |||
| 0b261b7198 | |||
| 3a9f32de25 | |||
| b5e54583c7 | |||
| 85ea7c1176 | |||
| 713f520bc8 | |||
| e4bb5a9395 | |||
| 936b2fe933 | |||
| c6c6f08885 | |||
| c621721851 | |||
| 5bb6b20641 | |||
| 37f41d8e09 | |||
| b02f312bed | |||
| 3520c821c5 | |||
| cbf737a03e | |||
| 5bd6d52e6a | |||
| d9a89beb3d | |||
| 41f783f14d | |||
| 35397b818d | |||
| d42d02f20a | |||
| 99c445f261 | |||
| 567fe85828 | |||
| fd1a5d0c5a | |||
| 632ec39d53 | |||
| 67b9d28953 | |||
| e3880eedb0 | |||
| ce64f5f902 | |||
| 0da99a50fc | |||
| 43f636be65 | |||
| 262cdbfab5 | |||
| 8cbd358435 | |||
| df04b19a0a | |||
| adeb352079 | |||
| 1e457600f1 |
@@ -24,6 +24,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -344,13 +344,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -523,14 +523,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
# - github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -39,7 +39,7 @@ on:
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
HA_SHORT_VERSION: "2026.7"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write # To label and close stale issues
|
||||
pull-requests: write # To label and close stale PRs
|
||||
actions: write # To delete stalebot state
|
||||
steps:
|
||||
# The 60 day stale policy for PRs
|
||||
# Used for:
|
||||
@@ -58,7 +59,7 @@ jobs:
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
|
||||
Generated
+2
@@ -718,6 +718,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
/homeassistant/components/hegel/ @boazca
|
||||
/tests/components/hegel/ @boazca
|
||||
/homeassistant/components/helty/ @ebaschiera
|
||||
/tests/components/helty/ @ebaschiera
|
||||
/homeassistant/components/heos/ @andrewsayre
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
|
||||
@@ -92,8 +92,7 @@ def _extract_backup(
|
||||
):
|
||||
ostf.tar.extractall(
|
||||
path=Path(tempdir, "extracted"),
|
||||
members=securetar.secure_path(ostf.tar),
|
||||
filter="fully_trusted",
|
||||
filter="tar",
|
||||
)
|
||||
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
||||
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
||||
@@ -119,8 +118,7 @@ def _extract_backup(
|
||||
) as istf:
|
||||
istf.extractall(
|
||||
path=Path(tempdir, "homeassistant"),
|
||||
members=securetar.secure_path(istf),
|
||||
filter="fully_trusted",
|
||||
filter="tar",
|
||||
)
|
||||
if restore_content.restore_homeassistant:
|
||||
keep = list(KEEP_BACKUPS)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
"requirements": ["aioamazondevices==13.8.2"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
@@ -37,6 +38,18 @@ STANDARD_SUPPORTED_FEATURES = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
|
||||
"""Describes an Alexa Devices media player entity."""
|
||||
|
||||
|
||||
MEDIA_PLAYERS: Final = (
|
||||
AmazonDevicesMediaPlayerEntityDescription(
|
||||
key="media",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
@@ -56,10 +69,9 @@ async def async_setup_entry(
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.append(
|
||||
AlexaDevicesMediaPlayer(
|
||||
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
|
||||
)
|
||||
new_entities.extend(
|
||||
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||
for description in MEDIA_PLAYERS
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
@@ -73,6 +85,8 @@ async def async_setup_entry(
|
||||
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
"""Representation of an Alexa device media player."""
|
||||
|
||||
entity_description: AmazonDevicesMediaPlayerEntityDescription
|
||||
|
||||
_attr_name = None # Uses the device name
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 0.05
|
||||
@@ -81,7 +95,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: MediaPlayerEntityDescription,
|
||||
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
@@ -200,7 +214,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Content type — tells HA what kind of media is playing."""
|
||||
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
|
||||
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
|
||||
return MediaType.MUSIC
|
||||
return None
|
||||
|
||||
@@ -213,8 +227,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
provider = media_type.value if isinstance(media_type, MediaType) else media_type
|
||||
await self.async_call_alexa_music(media_id, provider)
|
||||
await self.async_call_alexa_music(media_id, media_type)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
|
||||
@@ -372,7 +372,7 @@ def _convert_content( # noqa: C901
|
||||
)
|
||||
if (
|
||||
content.native.container is not None
|
||||
and content.native.container.expires_at > datetime.now(UTC)
|
||||
and content.native.container.expires_at > datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
):
|
||||
container_id = content.native.container.id
|
||||
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
|
||||
import avea
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothReachabilityIntent,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
)
|
||||
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type AveaConfigEntry = ConfigEntry[avea.Bulb]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -21,20 +15,12 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Set up Avea from a config entry."""
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
ble_device = async_ble_device_from_address(hass, address, connectable=True)
|
||||
ble_device = async_ble_device_from_address(
|
||||
hass, entry.data[CONF_ADDRESS], connectable=True
|
||||
)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
|
||||
)
|
||||
|
||||
entry.runtime_data = avea.Bulb(ble_device)
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Avea device with address {address}: {reason}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
|
||||
|
||||
@@ -6,7 +6,6 @@ from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.session import ApiHost
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -18,10 +17,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DEFAULT_SETUP_TIMEOUT
|
||||
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
|
||||
from .helpers import get_maybe_authenticated_session
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[Box]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -35,8 +33,6 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bool:
|
||||
"""Set up BleBox devices from a config entry."""
|
||||
@@ -58,7 +54,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
|
||||
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
entry.runtime_data = product
|
||||
coordinator = BleBoxCoordinator(hass, entry, product)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
BINARY_SENSOR_TYPES = (
|
||||
BinarySensorEntityDescription(
|
||||
key="moisture",
|
||||
@@ -27,23 +30,27 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxBinarySensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("binary_sensors", [])
|
||||
BleBoxBinarySensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("binary_sensors", [])
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity):
|
||||
"""Representation of a BleBox binary sensor feature."""
|
||||
|
||||
def __init__(
|
||||
self, feature: BinarySensorFeature, description: BinarySensorEntityDescription
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: BinarySensorFeature,
|
||||
description: BinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a BleBox binary sensor feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,7 +7,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -16,19 +20,22 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox button entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxButtonEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("buttons", [])
|
||||
BleBoxButtonEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("buttons", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
|
||||
"""Representation of BleBox buttons."""
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.button.Button) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
"""Initialize a BleBox button feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self._attr_icon = self.get_icon()
|
||||
|
||||
def get_icon(self) -> str | None:
|
||||
@@ -45,6 +52,7 @@ class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity
|
||||
return "mdi:arrow-down-circle"
|
||||
return None
|
||||
|
||||
@blebox_command
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._feature.set()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""BleBox climate entity."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.climate
|
||||
@@ -17,8 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BLEBOX_TO_HVACMODE = {
|
||||
0: HVACMode.OFF,
|
||||
@@ -40,11 +40,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox climate entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxClimateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("climates", [])
|
||||
BleBoxClimateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("climates", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
|
||||
@@ -108,6 +109,7 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
"""Return the desired thermostat temperature."""
|
||||
return self._feature.desired
|
||||
|
||||
@blebox_command
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the climate entity mode."""
|
||||
if hvac_mode in [HVACMode.HEAT, HVACMode.COOL]:
|
||||
@@ -116,6 +118,7 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
|
||||
await self._feature.async_off()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the thermostat temperature."""
|
||||
value = kwargs[ATTR_TEMPERATURE]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""DataUpdateCoordinator for BleBox devices."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[BleBoxCoordinator]
|
||||
|
||||
|
||||
class BleBoxCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator for a single BleBox device."""
|
||||
|
||||
config_entry: BleBoxConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: BleBoxConfigEntry, box: Box
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=5),
|
||||
)
|
||||
self.box = box
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from the BleBox device."""
|
||||
try:
|
||||
await self.box.async_update_data()
|
||||
except Error as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
@@ -17,7 +17,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BLEBOX_TO_COVER_DEVICE_CLASSES = {
|
||||
"gate": CoverDeviceClass.GATE,
|
||||
@@ -59,19 +63,22 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxCoverEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("covers", [])
|
||||
BleBoxCoverEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("covers", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Representation of a BleBox cover feature."""
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
|
||||
) -> None:
|
||||
"""Initialize a BleBox cover feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
@@ -135,33 +142,40 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Return whether cover is closed."""
|
||||
return self._is_state(CoverState.CLOSED)
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover position."""
|
||||
await self._feature.async_open()
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover position."""
|
||||
await self._feature.async_close()
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover tilt."""
|
||||
position = 50 if self._feature.is_tilt_180 else 0
|
||||
await self._feature.async_set_tilt_position(position)
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover tilt."""
|
||||
# note: values are reversed
|
||||
await self._feature.async_set_tilt_position(100)
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Set the cover position."""
|
||||
position = kwargs[ATTR_POSITION]
|
||||
await self._feature.async_set_position(100 - position)
|
||||
|
||||
@blebox_command
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self._feature.async_stop()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Set the tilt position."""
|
||||
position = kwargs[ATTR_TILT_POSITION]
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
"""Base entity for the BleBox devices integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.feature import Feature
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .coordinator import BleBoxCoordinator
|
||||
|
||||
|
||||
class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
"""Implements a common class for entities representing a BleBox feature."""
|
||||
|
||||
def __init__(self, feature: _FeatureT) -> None:
|
||||
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
|
||||
"""Initialize a BleBox entity."""
|
||||
super().__init__(coordinator)
|
||||
self._feature = feature
|
||||
self._attr_name = feature.full_name
|
||||
self._attr_unique_id = feature.unique_id
|
||||
@@ -30,10 +27,3 @@ class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
sw_version=product.firmware_version,
|
||||
configuration_url=f"http://{product.address}",
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity state."""
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
_LOGGER.error("Updating '%s' failed: %s", self.name, ex)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""BleBox light entities implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -24,11 +23,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -37,11 +38,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxLightEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("lights", [])
|
||||
BleBoxLightEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("lights", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
COLOR_MODE_MAP = {
|
||||
@@ -61,9 +63,11 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
_attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS
|
||||
_attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light
|
||||
) -> None:
|
||||
"""Initialize a BleBox light."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
if feature.effect_list:
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
@@ -165,6 +169,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
return None
|
||||
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex))
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
|
||||
@@ -224,6 +229,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
" effect list."
|
||||
) from exc
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self._feature.async_off()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import blebox_uniapi.sensor
|
||||
|
||||
@@ -28,9 +28,10 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
SENSOR_TYPES = (
|
||||
@@ -124,13 +125,14 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("sensors", [])
|
||||
BleBoxSensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("sensors", [])
|
||||
for description in SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
|
||||
@@ -138,11 +140,12 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: blebox_uniapi.sensor.BaseSensor,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a BleBox sensor feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -22,5 +22,10 @@
|
||||
"title": "Set up your BleBox device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"message": "An error occurred while communicating with the BleBox device: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""BleBox switch implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.switch
|
||||
@@ -11,8 +10,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,11 +21,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox switch entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSwitchEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("switches", [])
|
||||
BleBoxSwitchEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("switches", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity):
|
||||
@@ -38,10 +39,12 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
|
||||
"""Return whether switch is on."""
|
||||
return self._feature.is_on
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self._feature.async_turn_on()
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self._feature.async_turn_off()
|
||||
|
||||
@@ -18,8 +18,10 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
|
||||
|
||||
@@ -33,11 +35,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox update entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxUpdateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
||||
BleBoxUpdateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("updates", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
||||
@@ -48,9 +51,16 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return True because firmware versions cannot be fetched via coordinator."""
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.update.Update
|
||||
) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self._in_progress_old_version: str | None = None
|
||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
||||
self._poll_attempts: int = 0
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Utilities for BleBox."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
|
||||
def blebox_command[_BleBoxEntityT: BleBoxEntity, **_P, _R](
|
||||
func: Callable[Concatenate[_BleBoxEntityT, _P], Awaitable[_R]],
|
||||
) -> Callable[Concatenate[_BleBoxEntityT, _P], Coroutine[Any, Any, _R]]:
|
||||
"""Decorate BleBox calls that send commands to the device.
|
||||
|
||||
Catches BleBox errors and refreshes the coordinator after the command.
|
||||
"""
|
||||
|
||||
async def handler(self: _BleBoxEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except Error as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
return handler
|
||||
@@ -24,6 +24,7 @@ from homeassistant.components.alexa import (
|
||||
entities as alexa_entities,
|
||||
errors as alexa_errors,
|
||||
)
|
||||
from homeassistant.components.frontend import DATA_THEMES
|
||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
@@ -508,6 +509,15 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"custom_integrations": custom_integrations,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _get_themes_info(self, hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Collect information about user-installed custom themes."""
|
||||
themes: dict[str, Any] = hass.data.get(DATA_THEMES, {})
|
||||
return {
|
||||
"count": len(themes),
|
||||
"themes": sorted(themes),
|
||||
}
|
||||
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -569,6 +579,25 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
)
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Add custom themes information
|
||||
try:
|
||||
themes_info = self._get_themes_info(hass)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package generation
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += "Unable to collect themes information\n\n"
|
||||
else:
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += f"Custom themes: {themes_info['count']}\n\n"
|
||||
|
||||
if themes_info["themes"]:
|
||||
markdown += "<details><summary>Custom themes</summary>\n\n"
|
||||
markdown += "Name\n"
|
||||
markdown += "---\n"
|
||||
for theme in themes_info["themes"]:
|
||||
markdown += f"{theme}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Provide functionality to keep track of devices."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
@@ -22,6 +23,7 @@ from homeassistant.core import (
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
@@ -37,6 +39,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.loader import async_suggest_report_issue
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -52,6 +55,8 @@ from .const import (
|
||||
SourceType,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
||||
|
||||
|
||||
@@ -164,11 +169,35 @@ class BaseTrackerEntity(Entity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_source_type: SourceType
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "battery_level" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated battery_level property on "
|
||||
"a subclass of BaseTrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device.
|
||||
|
||||
Percentage from 0-100.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
return None
|
||||
|
||||
@@ -212,13 +241,38 @@ class TrackerEntity(
|
||||
_attr_in_zones: list[str] | None = None
|
||||
_attr_latitude: float | None = None
|
||||
_attr_location_accuracy: float = 0
|
||||
# _attr_location_name is deprecated and will be removed in Home Assistant 2027.7
|
||||
_attr_location_name: str | None = None
|
||||
_attr_longitude: float | None = None
|
||||
_attr_source_type: SourceType = SourceType.GPS
|
||||
|
||||
__active_zone: State | None = None
|
||||
# If we reported setting deprecated _attr_location_name
|
||||
__deprecated_attr_location_name_reported = False
|
||||
__in_zones: list[str] | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "location_name" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated location_name property on "
|
||||
"an instance of TrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling for entities that have location pushed."""
|
||||
@@ -249,7 +303,32 @@ class TrackerEntity(
|
||||
|
||||
@cached_property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device."""
|
||||
"""Return a location name for the current location of the device.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
if (location_name := self._attr_location_name) is not None:
|
||||
if (
|
||||
not self.__deprecated_attr_location_name_reported
|
||||
and not self.__class__.__module__.startswith(
|
||||
"homeassistant.components."
|
||||
)
|
||||
):
|
||||
report_issue = async_suggest_report_issue(
|
||||
self.hass, module=self.__class__.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is setting the deprecated _attr_location_name attribute "
|
||||
"on an instance of TrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
self.__class__.__module__,
|
||||
self.__class__.__name__,
|
||||
report_issue,
|
||||
)
|
||||
self.__deprecated_attr_location_name_reported = True
|
||||
return location_name
|
||||
return self._attr_location_name
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.2.4",
|
||||
"cached-ipaddress==1.1.1"
|
||||
"aiodiscover==3.3.1",
|
||||
"cached-ipaddress==1.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
|
||||
if warnings is None:
|
||||
return []
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
|
||||
|
||||
@property
|
||||
|
||||
@@ -106,7 +106,7 @@ async def async_migrate_entry(
|
||||
new_options = {**config_entry.options}
|
||||
|
||||
if config_entry.minor_version < 2:
|
||||
# Add defaults only if they’re not already present
|
||||
# Add defaults only if they're not already present
|
||||
if "stt_auto_language" not in new_options:
|
||||
new_options["stt_auto_language"] = False
|
||||
if "stt_model" not in new_options:
|
||||
|
||||
@@ -221,6 +221,7 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
|
||||
):
|
||||
try:
|
||||
value = float(current_state.state)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
|
||||
client.get_or_create_sensor(energyid_key).update(value, timestamp)
|
||||
except ValueError, TypeError:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather
|
||||
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada weather")
|
||||
|
||||
radar_data = ECRadar(coordinates=(lat, lon))
|
||||
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
|
||||
radar_coordinator = ECDataUpdateCoordinator(
|
||||
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for the Environment Canada radar imagery."""
|
||||
|
||||
from env_canada import ECRadar
|
||||
from env_canada import ECMap
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
@@ -11,13 +11,20 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_OBSERVATION_TIME
|
||||
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
|
||||
|
||||
SERVICE_SET_RADAR_TYPE = "set_radar_type"
|
||||
SET_RADAR_TYPE_SCHEMA: VolDictType = {
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow"]),
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow", "Precipitation type"]),
|
||||
}
|
||||
|
||||
_RADAR_TYPE_TO_LAYER: dict[str, str] = {
|
||||
"Rain": "rain",
|
||||
"Snow": "snow",
|
||||
"Precipitation type": "precip_type",
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +45,13 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera):
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
|
||||
"""Implementation of an Environment Canada radar camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "radar"
|
||||
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None:
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECMap]) -> None:
|
||||
"""Initialize the camera."""
|
||||
super().__init__(coordinator)
|
||||
Camera.__init__(self)
|
||||
@@ -76,6 +83,13 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
|
||||
|
||||
async def async_set_radar_type(self, radar_type: str) -> None:
|
||||
"""Set the type of radar to retrieve."""
|
||||
if radar_type == "Auto":
|
||||
# Choose rain for months April through October, snow otherwise
|
||||
layer = "rain" if dt_util.now().month in range(4, 11) else "snow"
|
||||
else:
|
||||
layer = _RADAR_TYPE_TO_LAYER[radar_type]
|
||||
|
||||
# Apply new layer and clear cache to force refresh
|
||||
self.radar_object.layer = layer
|
||||
self.radar_object.clear_cache()
|
||||
self.radar_object.precip_type = radar_type.lower()
|
||||
await self.radar_object.update()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -17,7 +17,7 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type ECConfigEntry = ConfigEntry[ECRuntimeData]
|
||||
type ECDataType = ECAirQuality | ECRadar | ECWeather
|
||||
type ECDataType = ECAirQuality | ECMap | ECWeather
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,7 +25,7 @@ class ECRuntimeData:
|
||||
"""Class to hold EC runtime data."""
|
||||
|
||||
aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECRadar]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECMap]
|
||||
weather_coordinator: ECDataUpdateCoordinator[ECWeather]
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ set_radar_type:
|
||||
fields:
|
||||
radar_type:
|
||||
required: true
|
||||
example: Snow
|
||||
example: Rain
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "Auto"
|
||||
- "Rain"
|
||||
- "Snow"
|
||||
- "Precipitation type"
|
||||
|
||||
@@ -161,7 +161,7 @@ class EvoChild(EvoEntity):
|
||||
or self._schedule is None
|
||||
or (
|
||||
(until := self._setpoints.get("next_sp_from")) is not None
|
||||
and until < datetime.now(UTC)
|
||||
and until < datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
)
|
||||
): # must use self._setpoints, not self.setpoints
|
||||
await get_schedule()
|
||||
|
||||
@@ -91,6 +91,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
|
||||
|
||||
session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
|
||||
if session_id_expires is None:
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
|
||||
else:
|
||||
self._session_id_expires = datetime.fromisoformat(session_id_expires)
|
||||
|
||||
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FlussConfigEntry, FlussDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -6,3 +6,4 @@ import logging
|
||||
DOMAIN = "fluss"
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_INTERVAL = timedelta(minutes=30)
|
||||
COMMAND_REFRESH_COOLDOWN = 10
|
||||
|
||||
@@ -13,10 +13,11 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import LOGGER, UPDATE_INTERVAL
|
||||
from .const import COMMAND_REFRESH_COOLDOWN, LOGGER, UPDATE_INTERVAL
|
||||
|
||||
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
|
||||
|
||||
@@ -35,18 +36,24 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
name=f"Fluss+ ({slugify(api_key[:8])})",
|
||||
config_entry=config_entry,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass,
|
||||
LOGGER,
|
||||
cooldown=COMMAND_REFRESH_COOLDOWN,
|
||||
immediate=False,
|
||||
),
|
||||
)
|
||||
|
||||
async def _async_get_connectivity(self, device_id: str) -> bool:
|
||||
"""Return connectivity for a device; False if the status call fails."""
|
||||
async def _async_get_status(self, device_id: str) -> dict[str, Any]:
|
||||
"""Return per-device status."""
|
||||
try:
|
||||
status = await self.api.async_get_device_status(device_id)
|
||||
except FlussApiClientError:
|
||||
return False
|
||||
return status["status"]["internetConnected"]
|
||||
response = await self.api.async_get_device_status(device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise UpdateFailed(f"Error fetching status for {device_id}: {err}") from err
|
||||
return response["status"]
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch Fluss+ devices and merge per-device connectivity status."""
|
||||
"""Fetch Fluss+ devices and merge per-device status."""
|
||||
try:
|
||||
devices = await self.api.async_get_devices()
|
||||
except FlussApiClientAuthenticationError as err:
|
||||
@@ -59,10 +66,11 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
for device in devices["devices"]
|
||||
if device["userPermissions"]["canUseWiFi"]
|
||||
]
|
||||
connectivity = await asyncio.gather(
|
||||
*(self._async_get_connectivity(d["deviceId"]) for d in device_list)
|
||||
|
||||
statuses = await asyncio.gather(
|
||||
*(self._async_get_status(d["deviceId"]) for d in device_list)
|
||||
)
|
||||
return {
|
||||
device["deviceId"]: {**device, "internetConnected": connected}
|
||||
for device, connected in zip(device_list, connectivity, strict=False)
|
||||
device["deviceId"]: {**device, **status}
|
||||
for device, status in zip(device_list, statuses, strict=False)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Cover platform for Fluss+ devices that report an open/closed status."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FlussApiClientError, FlussConfigEntry
|
||||
from .entity import FlussEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
STATUS_OPEN = "Open"
|
||||
STATUS_CLOSED = "Closed"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FlussConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fluss covers for devices that report an open/closed status."""
|
||||
coordinator = entry.runtime_data
|
||||
added_device_ids: set[str] = set()
|
||||
|
||||
def _async_add_new_entities() -> None:
|
||||
new_entities = [
|
||||
FlussCover(coordinator, device_id, device)
|
||||
for device_id, device in coordinator.data.items()
|
||||
if "openCloseStatus" in device and device_id not in added_device_ids
|
||||
]
|
||||
if not new_entities:
|
||||
return
|
||||
|
||||
added_device_ids.update(entity.device_id for entity in new_entities)
|
||||
async_add_entities(new_entities)
|
||||
|
||||
_async_add_new_entities()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
|
||||
|
||||
|
||||
class FlussCover(FlussEntity, CoverEntity):
|
||||
"""Representation of a Fluss+ cover."""
|
||||
|
||||
_attr_device_class = CoverDeviceClass.GARAGE
|
||||
_attr_name = None
|
||||
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True only when the device is online."""
|
||||
return super().available and self.device["internetConnected"]
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return whether the cover is closed."""
|
||||
status = self.device.get("openCloseStatus")
|
||||
if status == STATUS_CLOSED:
|
||||
return True
|
||||
if status == STATUS_OPEN:
|
||||
return False
|
||||
return None
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
try:
|
||||
await self.coordinator.api.async_open_device(self.device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="command_failed"
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
try:
|
||||
await self.coordinator.api.async_close_device(self.device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="command_failed"
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -19,5 +19,10 @@
|
||||
"description": "Your Fluss API key, available in the profile page of the Fluss+ app"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"command_failed": {
|
||||
"message": "Failed to send command to Fluss+ device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.4"]
|
||||
"requirements": ["home-assistant-frontend==20260527.2"]
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
|
||||
super()._handle_coordinator_update()
|
||||
return
|
||||
|
||||
time = datetime.now(UTC) + timedelta(seconds=value)
|
||||
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
|
||||
if not self._attr_native_value:
|
||||
self._attr_native_value = time
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -199,6 +199,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.RECEIVER): TYPE_RECEIVER,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.SPEAKER): TYPE_SPEAKER,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.TV): TYPE_TV,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.PROJECTOR): TYPE_TV,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.AQI): TYPE_SENSOR,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY): TYPE_SENSOR,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE): TYPE_SENSOR,
|
||||
|
||||
@@ -2728,7 +2728,11 @@ class ChannelTrait(_Trait):
|
||||
if (
|
||||
domain == media_player.DOMAIN
|
||||
and (features & MediaPlayerEntityFeature.PLAY_MEDIA)
|
||||
and device_class == media_player.MediaPlayerDeviceClass.TV
|
||||
and device_class
|
||||
in (
|
||||
media_player.MediaPlayerDeviceClass.TV,
|
||||
media_player.MediaPlayerDeviceClass.PROJECTOR,
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription):
|
||||
def _get_lowest_price_day_time(
|
||||
api: GreenPlanetEnergyAPI, data: dict[str, Any]
|
||||
) -> datetime | None:
|
||||
"""Return timestamp of the lowest-priced day hour (06:00–18:00)."""
|
||||
"""Return timestamp of the lowest-priced day hour (06:00-18:00)."""
|
||||
now = dt_util.now()
|
||||
now_h = now.hour
|
||||
hour = api.get_lowest_price_day_with_hour(data, now_h)[1]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""The Helty Flow integration."""
|
||||
|
||||
from pyhelty import HeltyClient
|
||||
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.FAN]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
|
||||
"""Set up Helty Flow from a config entry."""
|
||||
client = HeltyClient(entry.data[CONF_HOST])
|
||||
coordinator = HeltyDataUpdateCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Config flow for the Helty Flow integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyhelty import HeltyClient, HeltyConnectionError, HeltyError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
|
||||
class HeltyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Helty Flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial setup step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
client = HeltyClient(user_input[CONF_HOST])
|
||||
try:
|
||||
name = await client.async_get_name()
|
||||
except HeltyConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except HeltyError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=name or user_input[CONF_HOST], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Constants for the Helty Flow integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "helty"
|
||||
|
||||
#: How often the coordinator polls the unit.
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
# Fan preset mode identifiers (also used as translation keys).
|
||||
PRESET_BOOST = "boost"
|
||||
PRESET_NIGHT = "night"
|
||||
PRESET_FREE_COOLING = "free_cooling"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""DataUpdateCoordinator for the Helty Flow integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from pyhelty import HeltyClient, HeltyConnectionError, HeltyData, HeltyError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HeltyConfigEntry = ConfigEntry[HeltyDataUpdateCoordinator]
|
||||
|
||||
|
||||
class HeltyDataUpdateCoordinator(DataUpdateCoordinator[HeltyData]):
|
||||
"""Coordinate a single poll of the Helty unit for all entities."""
|
||||
|
||||
config_entry: HeltyConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HeltyConfigEntry,
|
||||
client: HeltyClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> HeltyData:
|
||||
try:
|
||||
return await self.client.async_get_data()
|
||||
except HeltyConnectionError as err:
|
||||
raise UpdateFailed(f"Error communicating with Helty unit: {err}") from err
|
||||
except HeltyError as err:
|
||||
raise UpdateFailed(f"Unexpected response from Helty unit: {err}") from err
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Base entity for the Helty Flow integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HeltyDataUpdateCoordinator
|
||||
|
||||
|
||||
class HeltyEntity(CoordinatorEntity[HeltyDataUpdateCoordinator]):
|
||||
"""Common base for Helty entities sharing one device and coordinator."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
|
||||
"""Initialize the entity and its shared device info."""
|
||||
super().__init__(coordinator)
|
||||
# The unit exposes no serial/MAC, so the config entry id identifies it.
|
||||
self._device_id = coordinator.config_entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._device_id)},
|
||||
name=coordinator.data.name,
|
||||
manufacturer="Helty",
|
||||
model="Flow",
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Fan platform for the Helty Flow integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyhelty import FanMode
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import PRESET_BOOST, PRESET_FREE_COOLING, PRESET_NIGHT
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
from .entity import HeltyEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# Ordered list of discrete fan speeds, lowest to highest.
|
||||
ORDERED_SPEEDS: list[FanMode] = [
|
||||
FanMode.LOW,
|
||||
FanMode.MEDIUM,
|
||||
FanMode.HIGH,
|
||||
FanMode.MAX,
|
||||
]
|
||||
|
||||
PRESET_TO_MODE: dict[str, FanMode] = {
|
||||
PRESET_BOOST: FanMode.BOOST,
|
||||
PRESET_NIGHT: FanMode.NIGHT,
|
||||
PRESET_FREE_COOLING: FanMode.FREE_COOLING,
|
||||
}
|
||||
MODE_TO_PRESET: dict[FanMode, str] = {
|
||||
mode: preset for preset, mode in PRESET_TO_MODE.items()
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HeltyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Helty fan."""
|
||||
async_add_entities([HeltyFan(entry.runtime_data)])
|
||||
|
||||
|
||||
class HeltyFan(HeltyEntity, FanEntity):
|
||||
"""The ventilation unit's fan, the device's primary feature."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_speed_count = len(ORDERED_SPEEDS)
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
|
||||
"""Initialize the fan."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = self._device_id
|
||||
self._attr_preset_modes = list(PRESET_TO_MODE)
|
||||
|
||||
@property
|
||||
def _mode(self) -> FanMode:
|
||||
return self.coordinator.data.fan_mode
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether the fan is running."""
|
||||
return self._mode is not FanMode.OFF
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed as a percentage, or None when on a preset."""
|
||||
if self._mode in ORDERED_SPEEDS:
|
||||
return ordered_list_item_to_percentage(ORDERED_SPEEDS, self._mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the active preset, or None when running on a discrete speed."""
|
||||
return MODE_TO_PRESET.get(self._mode)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set a discrete fan speed from a percentage."""
|
||||
if percentage == 0:
|
||||
await self._async_set_mode(FanMode.OFF)
|
||||
return
|
||||
await self._async_set_mode(
|
||||
percentage_to_ordered_list_item(ORDERED_SPEEDS, percentage)
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set a preset mode."""
|
||||
await self._async_set_mode(PRESET_TO_MODE[preset_mode])
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn the fan on."""
|
||||
if preset_mode is not None:
|
||||
await self.async_set_preset_mode(preset_mode)
|
||||
elif percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
else:
|
||||
await self._async_set_mode(FanMode.LOW)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
await self._async_set_mode(FanMode.OFF)
|
||||
|
||||
async def _async_set_mode(self, mode: FanMode) -> None:
|
||||
await self.coordinator.client.async_set_fan_mode(mode)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "helty",
|
||||
"name": "Helty Flow",
|
||||
"codeowners": ["@ebaschiera"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/helty",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyhelty"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyhelty==0.2.0"]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 subscribe to external 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:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration has no options to configure.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: This integration does not require authentication.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: The device does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
The device exposes no discovery protocol (no mDNS/SSDP) and no stable
|
||||
identifier such as a serial number or MAC over its interface.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: A config entry represents a single fixed device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: The only entity is the primary fan, which is enabled by default.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
The only entity is the primary fan, which uses the device name and has
|
||||
no name of its own to translate.
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: The fan entity uses the default fan icon.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration has no repairable issues to surface.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: A config entry represents a single fixed device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
The device is controlled over a raw TCP socket, not HTTP, so there is no
|
||||
web session to inject.
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address or hostname of the Helty Flow unit on your network."
|
||||
},
|
||||
"title": "Connect to your Helty Flow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""The homee cover platform."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pyHomee.const import AttributeType, NodeProfile
|
||||
from pyHomee.model import HomeeAttribute, HomeeNode
|
||||
@@ -35,6 +35,12 @@ COVER_DEVICE_PROFILES = {
|
||||
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
||||
}
|
||||
IS_CLOSED_ATTRIBUTES = [
|
||||
AttributeType.OPEN_CLOSE,
|
||||
AttributeType.UP_DOWN,
|
||||
AttributeType.POSITION,
|
||||
AttributeType.SHUTTER_SLAT_POSITION,
|
||||
]
|
||||
|
||||
|
||||
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
|
||||
@@ -83,9 +89,23 @@ async def add_cover_entities(
|
||||
nodes: list[HomeeNode],
|
||||
) -> None:
|
||||
"""Add homee cover entities."""
|
||||
async_add_entities(
|
||||
HomeeCover(node, config_entry) for node in nodes if is_cover_node(node)
|
||||
)
|
||||
entities: list[HomeeNode] = []
|
||||
for node in nodes:
|
||||
if is_cover_node(node):
|
||||
if any(
|
||||
node.get_attribute_by_type(attr) is not None
|
||||
for attr in IS_CLOSED_ATTRIBUTES
|
||||
):
|
||||
entities.append(node)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Cover %s could not be added, because it is missing an Attribute "
|
||||
"for closed indication. Please open an issue at "
|
||||
"https://github.com/home-assistant/core/issues",
|
||||
node.name,
|
||||
)
|
||||
|
||||
async_add_entities(HomeeCover(cover, config_entry) for cover in entities)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -187,7 +207,7 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
def is_closed(self) -> bool:
|
||||
"""Return if the cover is closed."""
|
||||
if (
|
||||
attribute := self._node.get_attribute_by_type(AttributeType.POSITION)
|
||||
@@ -200,15 +220,16 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
|
||||
return self._open_close_attribute.get_value() == 0
|
||||
|
||||
# If none of the above is present, it might be a slat only cover.
|
||||
if (
|
||||
attribute := self._node.get_attribute_by_type(
|
||||
AttributeType.SHUTTER_SLAT_POSITION
|
||||
)
|
||||
) is not None:
|
||||
return attribute.get_value() == attribute.minimum
|
||||
# If none of the above is present, it will be a slat only cover.
|
||||
attribute = self._node.get_attribute_by_type(
|
||||
AttributeType.SHUTTER_SLAT_POSITION
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
# This case should not happen, because we check for
|
||||
# the presence of an IS_CLOSED_ATTRIBUTE when adding entities.
|
||||
assert attribute is not None
|
||||
|
||||
return None
|
||||
return attribute.get_value() == attribute.minimum
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
|
||||
@@ -202,7 +202,10 @@ def get_accessory( # noqa: C901
|
||||
|
||||
if device_class == MediaPlayerDeviceClass.RECEIVER:
|
||||
a_type = "ReceiverMediaPlayer"
|
||||
elif device_class == MediaPlayerDeviceClass.TV:
|
||||
elif device_class in (
|
||||
MediaPlayerDeviceClass.TV,
|
||||
MediaPlayerDeviceClass.PROJECTOR,
|
||||
):
|
||||
a_type = "TelevisionMediaPlayer"
|
||||
elif validate_media_player_features(state, feature_list):
|
||||
a_type = "MediaPlayer"
|
||||
|
||||
@@ -695,7 +695,11 @@ def state_needs_accessory_mode(state: State) -> bool:
|
||||
return (
|
||||
state.domain == MEDIA_PLAYER_DOMAIN
|
||||
and state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
|
||||
in (
|
||||
MediaPlayerDeviceClass.TV,
|
||||
MediaPlayerDeviceClass.RECEIVER,
|
||||
MediaPlayerDeviceClass.PROJECTOR,
|
||||
)
|
||||
) or (
|
||||
state.domain == REMOTE_DOMAIN
|
||||
and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
@@ -339,6 +339,7 @@ class IntegrationSensor(RestoreSensor):
|
||||
else max_sub_interval
|
||||
)
|
||||
self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time: datetime = datetime.now(tz=UTC)
|
||||
self._last_integration_trigger = _IntegrationTrigger.StateEvent
|
||||
self._attr_suggested_display_precision = round_digits or 2
|
||||
@@ -498,6 +499,7 @@ class IntegrationSensor(RestoreSensor):
|
||||
old_timestamp, new_timestamp, old_state, new_state
|
||||
)
|
||||
self._last_integration_trigger = _IntegrationTrigger.StateEvent
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time = datetime.now(tz=UTC)
|
||||
finally:
|
||||
# When max_sub_interval exceeds without state change the source is assumed
|
||||
@@ -606,6 +608,7 @@ class IntegrationSensor(RestoreSensor):
|
||||
self._update_integral(area)
|
||||
self.async_write_ha_state()
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time = datetime.now(tz=UTC)
|
||||
self._last_integration_trigger = _IntegrationTrigger.TimeElapsed
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DATA_CONFIG, IZONE
|
||||
from .const import DATA_CONFIG, DOMAIN
|
||||
from .discovery import async_start_discovery_service, async_stop_discovery_service
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
IZONE: vol.Schema(
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_EXCLUDE, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
@@ -32,13 +32,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Register the iZone component config."""
|
||||
|
||||
# Check for manually added config, this may exclude some devices
|
||||
if conf := config.get(IZONE):
|
||||
if conf := config.get(DOMAIN):
|
||||
hass.data[DATA_CONFIG] = conf
|
||||
|
||||
# Explicitly added in the config file, create a config entry.
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
IZONE, context={"source": config_entries.SOURCE_IMPORT}
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ from .const import (
|
||||
DISPATCH_CONTROLLER_RECONNECTED,
|
||||
DISPATCH_CONTROLLER_UPDATE,
|
||||
DISPATCH_ZONE_UPDATE,
|
||||
IZONE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R]
|
||||
@@ -188,7 +188,7 @@ class ControllerDevice(ClimateEntity):
|
||||
|
||||
self._attr_unique_id = controller.device_uid
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(IZONE, controller.device_uid)},
|
||||
identifiers={(DOMAIN, controller.device_uid)},
|
||||
manufacturer="IZone",
|
||||
model=controller.sys_type,
|
||||
name=f"iZone Controller {controller.device_uid}",
|
||||
@@ -484,12 +484,12 @@ class ZoneDevice(ClimateEntity):
|
||||
assert controller.unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(IZONE, controller.unique_id, zone.index) # type:ignore[arg-type]
|
||||
(DOMAIN, controller.unique_id, zone.index) # type:ignore[arg-type]
|
||||
},
|
||||
manufacturer="IZone",
|
||||
model=zone.type.name.title(),
|
||||
name=zone.name.title(),
|
||||
via_device=(IZONE, controller.unique_id),
|
||||
via_device=(DOMAIN, controller.unique_id),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_entry_flow
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from .const import DISPATCH_CONTROLLER_DISCOVERED, IZONE, TIMEOUT_DISCOVERY
|
||||
from .const import DISPATCH_CONTROLLER_DISCOVERED, DOMAIN, TIMEOUT_DISCOVERY
|
||||
from .discovery import async_start_discovery_service, async_stop_discovery_service
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -39,4 +39,4 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
config_entry_flow.register_discovery_flow(IZONE, "iZone Aircon", _async_has_devices)
|
||||
config_entry_flow.register_discovery_flow(DOMAIN, "iZone Aircon", _async_has_devices)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Constants used by the izone component."""
|
||||
|
||||
IZONE = "izone"
|
||||
DOMAIN = "izone"
|
||||
|
||||
DATA_DISCOVERY_SERVICE = "izone_discovery"
|
||||
DATA_CONFIG = "izone_config"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.15.0",
|
||||
"xknxproject==3.9.0",
|
||||
"knx-frontend==2026.6.1.213802"
|
||||
"knx-frontend==2026.4.30.60856"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -105,10 +105,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
except ThinQAPIException as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
exc.message, translation_domain=DOMAIN, translation_key=exc.code
|
||||
) from exc
|
||||
raise ServiceValidationError(exc.message) from exc
|
||||
except ValueError as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
|
||||
"requirements": [
|
||||
"aiolifx==1.2.1",
|
||||
"aiolifx==1.2.2",
|
||||
"aiolifx-effects==0.3.2",
|
||||
"aiolifx-themes==1.0.2"
|
||||
"aiolifx-themes==1.0.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.web import Request
|
||||
from loqedAPI import loqed
|
||||
|
||||
@@ -160,14 +161,20 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]):
|
||||
|
||||
_LOGGER.debug("Webhook URL: %s", webhook_url)
|
||||
|
||||
webhooks = await self.lock.getWebhooks()
|
||||
try:
|
||||
webhooks = await self.lock.getWebhooks()
|
||||
|
||||
webhook_index = next(
|
||||
(x["id"] for x in webhooks if x["url"] == webhook_url), None
|
||||
)
|
||||
webhook_index = next(
|
||||
(x["id"] for x in webhooks if x["url"] == webhook_url), None
|
||||
)
|
||||
|
||||
if webhook_index:
|
||||
await self.lock.deleteWebhook(webhook_index)
|
||||
if webhook_index:
|
||||
await self.lock.deleteWebhook(webhook_index)
|
||||
except (TimeoutError, aiohttp.ClientError) as err:
|
||||
_LOGGER.warning(
|
||||
"Could not remove webhook from LOQED bridge; the bridge may be offline. Continuing to unload the entry anyway: %s",
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
async def async_cloudhook_generate_url(
|
||||
|
||||
@@ -97,6 +97,11 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self._data[CONF_URL] = url
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
"model": discovery_info.properties["device"],
|
||||
"name": discovery_info.name.rsplit(" ", maxsplit=1)[0],
|
||||
}
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
|
||||
"missing_device_info": "Failed to read device information. Check the network connection of the device"
|
||||
},
|
||||
"flow_title": "{name} ({model})",
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to set up the Lunatone device at {url}?"
|
||||
|
||||
@@ -556,4 +556,48 @@ DISCOVERY_SCHEMAS = [
|
||||
featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kOccupancy,
|
||||
allow_multi=True,
|
||||
),
|
||||
# GeneralDiagnostics active fault sensors
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="GeneralDiagnosticsActiveHardwareFaults",
|
||||
translation_key="active_hardware_faults",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_to_ha=bool,
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(
|
||||
clusters.GeneralDiagnostics.Attributes.ActiveHardwareFaults,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="GeneralDiagnosticsActiveRadioFaults",
|
||||
translation_key="active_radio_faults",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_to_ha=bool,
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(clusters.GeneralDiagnostics.Attributes.ActiveRadioFaults,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="GeneralDiagnosticsActiveNetworkFaults",
|
||||
translation_key="active_network_faults",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_to_ha=bool,
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(
|
||||
clusters.GeneralDiagnostics.Attributes.ActiveNetworkFaults,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -457,7 +457,14 @@ class MatterLight(MatterEntity, LightEntity):
|
||||
self._transitions_disabled = True
|
||||
LOGGER.warning(
|
||||
"Detected a device that has been reported to have firmware issues "
|
||||
"with light transitions. Transitions will be disabled for this light"
|
||||
"with light transitions. Transitions will be disabled for this "
|
||||
"light: %s %s (vendor_id: %s, product_id: %s, hw: %s, sw: %s)",
|
||||
device_info.vendorName,
|
||||
device_info.productName,
|
||||
device_info.vendorID,
|
||||
device_info.productID,
|
||||
device_info.hardwareVersionString,
|
||||
device_info.softwareVersionString,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ DISCOVERY_SCHEMAS = [
|
||||
None if x is None else min(x, 200) / 2
|
||||
) # Matter range (1-200, capped at 200)
|
||||
),
|
||||
ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0%
|
||||
ha_to_device=lambda x: round(x * 2), # HA range 0.5-100.0%
|
||||
mode=NumberMode.SLIDER,
|
||||
),
|
||||
entity_class=MatterLevelControlNumber,
|
||||
|
||||
@@ -27,6 +27,7 @@ from homeassistant.const import (
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
REVOLUTIONS_PER_MINUTE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
UnitOfApparentPower,
|
||||
@@ -137,6 +138,28 @@ RVC_OPERATIONAL_STATE_ERROR_MAP = {
|
||||
_rvc_err.kNavigationSensorObscured: ("navigation_sensor_obscured"),
|
||||
}
|
||||
|
||||
THREAD_ROUTING_ROLE_MAP = {
|
||||
clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kUnspecified: "unspecified",
|
||||
clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kUnassigned: "unassigned",
|
||||
clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kSleepyEndDevice: "sleepy_end_device",
|
||||
clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kEndDevice: "end_device",
|
||||
clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kReed: "reed",
|
||||
clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kRouter: "router",
|
||||
clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kLeader: "leader",
|
||||
clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kUnknownEnumValue: "unknown",
|
||||
}
|
||||
|
||||
BOOT_REASON_MAP = {
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnspecified: "unspecified",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kPowerOnReboot: "power_on_reboot",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kBrownOutReset: "brown_out_reset",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareWatchdogReset: "software_watchdog_reset",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kHardwareWatchdogReset: "hardware_watchdog_reset",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareUpdateCompleted: "software_update_completed",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareReset: "software_reset",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnknownEnumValue: None,
|
||||
}
|
||||
|
||||
BOOST_STATE_MAP = {
|
||||
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive",
|
||||
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active",
|
||||
@@ -428,6 +451,19 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
allow_multi=True, # also used for climate entity
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="SoilMoistureSensor",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.MOISTURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(
|
||||
clusters.SoilMeasurement.Attributes.SoilMoistureMeasuredValue,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
@@ -1575,4 +1611,98 @@ DISCOVERY_SCHEMAS = [
|
||||
required_attributes=(clusters.DoorLock.Attributes.DoorClosedEvents,),
|
||||
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
|
||||
),
|
||||
# WiFiNetworkDiagnostics cluster sensors
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="WiFiDiagnosticsRssi",
|
||||
translation_key="wifi_rssi",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.WiFiNetworkDiagnostics.Attributes.Rssi,),
|
||||
),
|
||||
# ThreadNetworkDiagnostics cluster sensors
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ThreadDiagnosticsChannel",
|
||||
translation_key="thread_channel",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.ThreadNetworkDiagnostics.Attributes.Channel,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ThreadDiagnosticsRoutingRole",
|
||||
translation_key="thread_routing_role",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
options=list(THREAD_ROUTING_ROLE_MAP.values()),
|
||||
device_to_ha=lambda value: THREAD_ROUTING_ROLE_MAP.get(value, "unknown"),
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.ThreadNetworkDiagnostics.Attributes.RoutingRole,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ThreadDiagnosticsNetworkName",
|
||||
translation_key="thread_network_name",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.ThreadNetworkDiagnostics.Attributes.NetworkName,),
|
||||
),
|
||||
# GeneralDiagnostics cluster sensors
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="GeneralDiagnosticsRebootCount",
|
||||
translation_key="reboot_count",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.GeneralDiagnostics.Attributes.RebootCount,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="GeneralDiagnosticsUpTime",
|
||||
translation_key="uptime",
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_to_ha=lambda uptime: dt_util.utcnow() - timedelta(seconds=uptime),
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.GeneralDiagnostics.Attributes.UpTime,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="GeneralDiagnosticsBootReason",
|
||||
translation_key="boot_reason",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
options=[
|
||||
reason for reason in BOOT_REASON_MAP.values() if reason is not None
|
||||
],
|
||||
device_to_ha=BOOT_REASON_MAP.get,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.GeneralDiagnostics.Attributes.BootReason,),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -47,6 +47,15 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"active_hardware_faults": {
|
||||
"name": "Hardware faults"
|
||||
},
|
||||
"active_network_faults": {
|
||||
"name": "Network faults"
|
||||
},
|
||||
"active_radio_faults": {
|
||||
"name": "Radio faults"
|
||||
},
|
||||
"actuator": {
|
||||
"name": "Actuator"
|
||||
},
|
||||
@@ -408,6 +417,18 @@
|
||||
"battery_voltage": {
|
||||
"name": "Battery voltage"
|
||||
},
|
||||
"boot_reason": {
|
||||
"name": "Boot reason",
|
||||
"state": {
|
||||
"brown_out_reset": "Brownout reset",
|
||||
"hardware_watchdog_reset": "Hardware watchdog reset",
|
||||
"power_on_reboot": "Power-on reboot",
|
||||
"software_reset": "Software reset",
|
||||
"software_update_completed": "Software update completed",
|
||||
"software_watchdog_reset": "Software watchdog reset",
|
||||
"unspecified": "Unspecified"
|
||||
}
|
||||
},
|
||||
"contamination_state": {
|
||||
"name": "Contamination state",
|
||||
"state": {
|
||||
@@ -576,6 +597,9 @@
|
||||
"reactive_current": {
|
||||
"name": "Reactive current"
|
||||
},
|
||||
"reboot_count": {
|
||||
"name": "Reboot count"
|
||||
},
|
||||
"rms_current": {
|
||||
"name": "Effective current"
|
||||
},
|
||||
@@ -591,6 +615,25 @@
|
||||
"tank_volume": {
|
||||
"name": "Tank volume"
|
||||
},
|
||||
"thread_channel": {
|
||||
"name": "Thread channel"
|
||||
},
|
||||
"thread_network_name": {
|
||||
"name": "Thread network name"
|
||||
},
|
||||
"thread_routing_role": {
|
||||
"name": "Thread routing role",
|
||||
"state": {
|
||||
"end_device": "End device",
|
||||
"leader": "Leader",
|
||||
"reed": "Router eligible end device",
|
||||
"router": "Router",
|
||||
"sleepy_end_device": "Sleepy end device",
|
||||
"unassigned": "Unassigned",
|
||||
"unknown": "Unknown",
|
||||
"unspecified": "Unspecified"
|
||||
}
|
||||
},
|
||||
"tvoc_level": {
|
||||
"name": "TVOC level",
|
||||
"state": {
|
||||
@@ -600,12 +643,18 @@
|
||||
"medium": "[%key:common::state::medium%]"
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
"name": "Uptime"
|
||||
},
|
||||
"valve_position": {
|
||||
"name": "Valve position"
|
||||
},
|
||||
"voltage": {
|
||||
"name": "Voltage"
|
||||
},
|
||||
"wifi_rssi": {
|
||||
"name": "Wi-Fi RSSI"
|
||||
},
|
||||
"window_covering_target_position": {
|
||||
"name": "Target opening position"
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ class MediaPlayerDeviceClass(StrEnum):
|
||||
TV = "tv"
|
||||
SPEAKER = "speaker"
|
||||
RECEIVER = "receiver"
|
||||
PROJECTOR = "projector"
|
||||
|
||||
|
||||
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
"playing": "mdi:cast-connected"
|
||||
}
|
||||
},
|
||||
"projector": {
|
||||
"default": "mdi:projector",
|
||||
"state": {
|
||||
"off": "mdi:projector-off"
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"default": "mdi:audio-video",
|
||||
"state": {
|
||||
|
||||
@@ -261,6 +261,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"projector": {
|
||||
"name": "Projector"
|
||||
},
|
||||
"receiver": {
|
||||
"name": "Receiver"
|
||||
},
|
||||
|
||||
@@ -798,7 +798,7 @@ class MQTT:
|
||||
keepalive=self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
|
||||
# See:
|
||||
# https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html
|
||||
# `clean_start` (bool) – (MQTT v5.0 only) `True`, `False` or
|
||||
# `clean_start` (bool) - (MQTT v5.0 only) `True`, `False` or
|
||||
# `MQTT_CLEAN_START_FIRST_ONLY`. Sets the MQTT v5.0 clean_start flag
|
||||
# always, never or on the first successful connect only,
|
||||
# respectively. MQTT session data (such as outstanding messages and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Helper for Netatmo integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pyatmo.modules.device_types import DeviceType as NetatmoDeviceType
|
||||
@@ -25,4 +25,4 @@ class NetatmoArea:
|
||||
lon_sw: float
|
||||
mode: str
|
||||
show_on_map: bool
|
||||
uuid: UUID = uuid4()
|
||||
uuid: UUID = field(default_factory=uuid4)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Provide diagnostics for OpenEVSE."""
|
||||
|
||||
import asyncio
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import OpenEVSEConfigEntry
|
||||
|
||||
REDACT_CONFIG_DATA = {CONF_PASSWORD, CONF_USERNAME}
|
||||
CHARGER_PROPERTIES = (
|
||||
"status",
|
||||
"vehicle",
|
||||
"mode",
|
||||
"charge_mode",
|
||||
"divertmode",
|
||||
"manual_override",
|
||||
"ota_update",
|
||||
"service_level",
|
||||
"charge_time_elapsed",
|
||||
"vehicle_eta",
|
||||
"charging_current",
|
||||
"charging_voltage",
|
||||
"charging_power",
|
||||
"current_power",
|
||||
"current_capacity",
|
||||
"max_current",
|
||||
"min_amps",
|
||||
"max_amps",
|
||||
"max_current_soft",
|
||||
"available_current",
|
||||
"smoothed_available_current",
|
||||
"charge_rate",
|
||||
"ambient_temperature",
|
||||
"ir_temperature",
|
||||
"rtc_temperature",
|
||||
"esp_temperature",
|
||||
"usage_session",
|
||||
"usage_total",
|
||||
"total_day",
|
||||
"total_week",
|
||||
"total_month",
|
||||
"total_year",
|
||||
"vehicle_soc",
|
||||
"vehicle_range",
|
||||
"wifi_signal",
|
||||
"shaper_live_power",
|
||||
"shaper_available_current",
|
||||
"shaper_max_power",
|
||||
"gfi_trip_count",
|
||||
"no_gnd_trip_count",
|
||||
"stuck_relay_trip_count",
|
||||
"uptime",
|
||||
"freeram",
|
||||
"wifi_firmware",
|
||||
"openevse_firmware",
|
||||
)
|
||||
|
||||
|
||||
def _to_json_safe(val: Any) -> Any:
|
||||
"""Coerce value to be JSON-serializable."""
|
||||
if isinstance(val, (datetime, date)):
|
||||
return val.isoformat()
|
||||
if isinstance(val, Enum):
|
||||
return val.value
|
||||
return val
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
_hass: HomeAssistant, config_entry: OpenEVSEConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
charger = coordinator.charger
|
||||
|
||||
charger_data: dict[str, Any] = {}
|
||||
|
||||
for prop in CHARGER_PROPERTIES:
|
||||
try:
|
||||
val = getattr(charger, prop)
|
||||
except AttributeError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
charger_data[prop] = f"Error: {type(err).__name__}"
|
||||
continue
|
||||
|
||||
# Top-level callables on the charger object are omitted from diagnostics.
|
||||
if callable(val):
|
||||
continue
|
||||
|
||||
charger_data[prop] = _to_json_safe(val)
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(config_entry.as_dict(), REDACT_CONFIG_DATA),
|
||||
"charger": charger_data,
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
"""The openSenseMap integration."""
|
||||
|
||||
from opensensemap_api import OpenSenseMap
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_STATION_ID
|
||||
from .coordinator import OpenSenseMapConfigEntry, OpenSenseMapCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.AIR_QUALITY]
|
||||
|
||||
type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMap]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: OpenSenseMapConfigEntry
|
||||
@@ -22,14 +18,10 @@ async def async_setup_entry(
|
||||
"""Set up openSenseMap from a config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
api = OpenSenseMap(entry.data[CONF_STATION_ID], session)
|
||||
try:
|
||||
await api.get_data()
|
||||
except OpenSenseMapError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to fetch data from openSenseMap: {err}"
|
||||
) from err
|
||||
coordinator = OpenSenseMapCoordinator(hass, entry, api)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = api
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"""Support for openSenseMap Air Quality data."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from opensensemap_api import OpenSenseMap
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.air_quality import (
|
||||
@@ -20,18 +16,16 @@ from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import OpenSenseMapConfigEntry
|
||||
from .const import (
|
||||
CONF_STATION_ID,
|
||||
DEPRECATED_YAML_BREAKS_IN_VERSION,
|
||||
DOMAIN,
|
||||
INTEGRATION_TITLE,
|
||||
KNOWN_IMPORT_ABORT_REASONS,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
from .coordinator import OpenSenseMapConfigEntry, OpenSenseMapCoordinator
|
||||
|
||||
PLATFORM_SCHEMA = AIR_QUALITY_PLATFORM_SCHEMA.extend(
|
||||
{vol.Required(CONF_STATION_ID): cv.string, vol.Optional(CONF_NAME): cv.string}
|
||||
@@ -107,33 +101,25 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class OpenSenseMapQuality(AirQualityEntity):
|
||||
class OpenSenseMapQuality(CoordinatorEntity[OpenSenseMapCoordinator], AirQualityEntity):
|
||||
"""Implementation of an openSenseMap air quality entity."""
|
||||
|
||||
_attr_attribution = "Data provided by openSenseMap"
|
||||
|
||||
def __init__(self, api: OpenSenseMap, station_id: str, name: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: OpenSenseMapCoordinator, station_id: str, name: str
|
||||
) -> None:
|
||||
"""Initialize the air quality entity."""
|
||||
self._api = api
|
||||
super().__init__(coordinator)
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = station_id
|
||||
|
||||
@property
|
||||
def particulate_matter_2_5(self) -> float | None:
|
||||
"""Return the particulate matter 2.5 level."""
|
||||
return self._api.pm2_5
|
||||
return self.coordinator.data.pm2_5
|
||||
|
||||
@property
|
||||
def particulate_matter_10(self) -> float | None:
|
||||
"""Return the particulate matter 10 level."""
|
||||
return self._api.pm10
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch latest data from the openSenseMap API."""
|
||||
try:
|
||||
await self._api.get_data()
|
||||
except OpenSenseMapError as err:
|
||||
LOGGER.warning("Unable to fetch data from openSenseMap: %s", err)
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
return self.coordinator.data.pm10
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Data update coordinator for the openSenseMap integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from opensensemap_api import OpenSenseMap
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OpenSenseMapStationData:
|
||||
"""Immutable measurements for an openSenseMap station."""
|
||||
|
||||
pm2_5: float | None
|
||||
pm10: float | None
|
||||
|
||||
|
||||
type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMapCoordinator]
|
||||
|
||||
|
||||
class OpenSenseMapCoordinator(DataUpdateCoordinator[OpenSenseMapStationData]):
|
||||
"""Coordinator to manage data updates for an openSenseMap station."""
|
||||
|
||||
config_entry: OpenSenseMapConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: OpenSenseMapConfigEntry,
|
||||
api: OpenSenseMap,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> OpenSenseMapStationData:
|
||||
"""Fetch latest data from the openSenseMap API."""
|
||||
try:
|
||||
await self.api.get_data()
|
||||
except OpenSenseMapError as err:
|
||||
raise UpdateFailed(
|
||||
f"Unable to fetch data from openSenseMap: {err}"
|
||||
) from err
|
||||
return OpenSenseMapStationData(pm2_5=self.api.pm2_5, pm10=self.api.pm10)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Device tracker support for OPNsense routers."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.device_tracker import ScannerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -96,3 +98,20 @@ class OPNsenseDeviceTrackerEntity(
|
||||
hostname = device_data.get("hostname")
|
||||
return hostname or None
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
device_data = self.device_data
|
||||
if not device_data:
|
||||
return {}
|
||||
|
||||
attrs = {}
|
||||
if manufacturer := device_data.get("manufacturer"):
|
||||
attrs["manufacturer"] = manufacturer
|
||||
if interface := device_data.get("intf_description"):
|
||||
attrs["interface"] = interface
|
||||
if expires := device_data.get("expires"):
|
||||
attrs["expires"] = expires
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -93,8 +93,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
|
||||
server=SUPPORTED_SERVERS[entry.data[CONF_HUB]],
|
||||
)
|
||||
|
||||
await _async_migrate_entries(hass, entry)
|
||||
|
||||
try:
|
||||
await client.login()
|
||||
setup = await client.get_setup()
|
||||
@@ -196,10 +194,24 @@ async def async_unload_entry(
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def _async_migrate_entries(
|
||||
hass: HomeAssistant, config_entry: OverkizDataConfigEntry
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: OverkizDataConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entries to new unique IDs."""
|
||||
"""Migrate old entry."""
|
||||
if entry.version > 1:
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
await _async_migrate_strenum_unique_ids(hass, entry)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_migrate_strenum_unique_ids(
|
||||
hass: HomeAssistant, config_entry: OverkizDataConfigEntry
|
||||
) -> None:
|
||||
"""Migrate entities to the StrEnum-style unique IDs."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@callback
|
||||
@@ -256,8 +268,6 @@ async def _async_migrate_entries(
|
||||
|
||||
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_local_client(
|
||||
hass: HomeAssistant, host: str, token: str, verify_ssl: bool
|
||||
|
||||
@@ -96,7 +96,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [
|
||||
# DomesticHotWaterProduction/WaterHeatingSystem
|
||||
OverkizBinarySensorDescription(
|
||||
key=OverkizState.IO_OPERATING_MODE_CAPABILITIES,
|
||||
name="Energy Demand Status",
|
||||
name="Energy demand status",
|
||||
device_class=BinarySensorDeviceClass.HEAT,
|
||||
value_fn=lambda state: (
|
||||
cast(dict, state).get(OverkizCommandParam.ENERGY_DEMAND_STATUS) == 1
|
||||
|
||||
@@ -40,6 +40,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Overkiz (by Somfy)."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
_verify_ssl: bool = True
|
||||
_api_type: APIType = APIType.CLOUD
|
||||
|
||||
@@ -13,6 +13,7 @@ from pyoverkiz.exceptions import (
|
||||
InvalidEventListenerIdException,
|
||||
MaintenanceException,
|
||||
NotAuthenticatedException,
|
||||
ServiceUnavailableException,
|
||||
TooManyConcurrentRequestsException,
|
||||
TooManyRequestsException,
|
||||
)
|
||||
@@ -85,6 +86,8 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
raise UpdateFailed("Too many requests, try again later.") from exception
|
||||
except MaintenanceException as exception:
|
||||
raise UpdateFailed("Server is down for maintenance.") from exception
|
||||
except ServiceUnavailableException as exception:
|
||||
raise UpdateFailed("Server is unavailable.") from exception
|
||||
except InvalidEventListenerIdException as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
except (TimeoutError, ClientConnectorError) as exception:
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""The OVHcloud AI Endpoints integration."""
|
||||
|
||||
from openai import AsyncOpenAI, AuthenticationError, BadRequestError, OpenAIError
|
||||
from openai import (
|
||||
AsyncOpenAI,
|
||||
AuthenticationError,
|
||||
BadRequestError,
|
||||
OpenAIError,
|
||||
PermissionDeniedError,
|
||||
)
|
||||
from openai.types.chat import ChatCompletionUserMessageParam
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -52,7 +58,7 @@ async def async_setup_entry(
|
||||
|
||||
try:
|
||||
await _validate_api_key(client)
|
||||
except AuthenticationError as err:
|
||||
except (AuthenticationError, PermissionDeniedError) as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except OpenAIError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Config flow for the OVHcloud AI Endpoints integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from openai import AsyncOpenAI, AuthenticationError, OpenAIError
|
||||
from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -30,6 +31,8 @@ from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
|
||||
|
||||
class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OVHcloud AI Endpoints."""
|
||||
@@ -55,7 +58,7 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
client = _create_client(self.hass, user_input[CONF_API_KEY])
|
||||
try:
|
||||
await _validate_api_key(client)
|
||||
except AuthenticationError:
|
||||
except AuthenticationError, PermissionDeniedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except OpenAIError:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -77,6 +80,39 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
client = _create_client(self.hass, user_input[CONF_API_KEY])
|
||||
try:
|
||||
await _validate_api_key(client)
|
||||
except AuthenticationError, PermissionDeniedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except OpenAIError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates=user_input,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ConversationFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle conversation subentry flow."""
|
||||
|
||||
@@ -12,6 +12,8 @@ from . import OVHcloudAIEndpointsConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import OVHcloudAIEndpointsEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Diagnostics support for OVHcloud AI Endpoints."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from openai import __title__, __version__
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PROMPT
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import OVHcloudAIEndpointsConfigEntry
|
||||
|
||||
|
||||
TO_REDACT = {CONF_API_KEY, CONF_PROMPT}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"client": f"{__title__}=={__version__}",
|
||||
"title": entry.title,
|
||||
"entry_id": entry.entry_id,
|
||||
"entry_version": f"{entry.version}.{entry.minor_version}",
|
||||
"state": entry.state.value,
|
||||
"data": async_redact_data(entry.data, TO_REDACT),
|
||||
"options": async_redact_data(entry.options, TO_REDACT),
|
||||
"subentries": {
|
||||
subentry.subentry_id: {
|
||||
"title": subentry.title,
|
||||
"subentry_type": subentry.subentry_type,
|
||||
"data": async_redact_data(subentry.data, TO_REDACT),
|
||||
}
|
||||
for subentry in entry.subentries.values()
|
||||
},
|
||||
"entities": {
|
||||
entity_entry.entity_id: entity_entry.extended_dict
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
er.async_get(hass), entry.entry_id
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ovhcloud_ai_endpoints",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["openai==2.21.0"]
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
@@ -43,13 +45,13 @@ rules:
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: the integration only integrates stateless entities
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Service can't be discovered
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user